2020-10-29 08:00:00

将棋ソフト開発 Week2 ~ USIに対応する! ~

プログラミングの勉強がてらに、将棋ソフト開発をしています。前回の1週目では将棋のコア部分について実装しましたが、2週目ではGUIを使えるようにUSIプロとコロルに対応をしていきます。2週目でやったこと、進捗報告、これからやることについて記載してきます。

これまでの記事はこちら 将棋ソフト開発 Week0 ~ 将棋アプリ開発始めます ~ | なぎなぎブログ将棋ソフト開発 Week1 ~ 将棋のコア部分を作る ~ | なぎなぎブログ

目次

進捗報告

最低限のUSIプロトコルに対応して将棋所や将棋GUIで動くようになりました!

2週目でやったこと

  • sfenの理解
  • USIプロトコルの対応
  • 合法手のみ指すように修正

sfenの理解

sfenとは

SFEN(Shogi Forsyth-Edwards Notation) は将棋における駒の配置や、持ち駒、手番など局面の表記方法です。 もともとFENと言われる、チェスの局面の表記法がありそれの将棋版という感じのようです。

sfenは次のように4つの要素から構成されます 「<盤面の表記> <先手・後手の表記> <持ち駒の表記> <手数>」

例えば平手の初期局面は次のように表されます。 「lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1」

パット見ですとよくわからない文字列が続いてますね。 表記の仕方について詳しく見ていきます。

駒の種類の表記

先手の駒は大文字で表記、後手の駒は小文字で表記となります。

駒種先手駒表記後手駒表記補足
KkKingの頭文字
飛車RrRookの頭文字
BbBishopの頭文字
GgGoldの頭文字
SsSilverの頭文字
桂馬NnkNightより
香車LlLanceの頭文字
PpPawnの頭文字

チェスをやってる方なら、金、銀、香 以外はチェスにも似た駒があるのでわかるかと思います。 金のGだけはGinとかと間違わないように注意ですね!

これに加え成駒の場合は駒の文字種の前に、「+」をつけます。 例えば先手のと金は「+P」と表記されますし、後手の成銀は「+s」といった感じです

盤面の表記

段ごとに表記し、各段は/によって区切られます。

<1段目の表記>/<2段目の表記>/<3段目の表記>/<4段目の表記>/<5段目の表記>/<6段目の表記>/<7段目の表記>/<8段目の表記>/<9段目の表記>

といった感じです。

格段は「駒の種類の表記」の項で説明した表記により、左側のマスから順に駒を表記します。

例えば将棋の平手初期局面のい1段目は「香桂銀金玉金銀桂香」と並ぶので対応する表記をすると「lnsgkgsnl」となります 2段目は「空飛空空空空空角空」と並んでますよね。※空きますを「空」としてます。

空きマスのときはいくつ空きマスがあるかで表現をします。 左から、 1つ空きマス、飛車、5つ空きマス、角、1つ空きマス これをSFENの表記だと「1r5b1」となります。

3段目は歩が「歩歩歩歩歩歩歩歩歩」と並んでるので「ppppppppp」表される。 4段目は空きマスが9個ならんでいるので「9」と表される。

この具合で9段目まで表すと

「lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL」という表記になります。

先手・後手の表記

先手であれば「b」(Blackの頭文字) 後手であれば「w」(Whiteの頭文字)

持ち駒の表記

持ち駒が先手後手共に何もないときは「-」と表記されます。

持っている駒種の表記は「駒の種類の表記」で説明したとおりですが、複数枚駒を持っているときは「<持っている駒の枚数><駒の種類>」という感じで表記されます。 例えば歩を3枚持っていたら「3P」というかんじですね。ちなみに1枚のときはコマの枚数は省略されてそのまま「P」と表記されます。

表記する際の順序ですが、まず先手の持ち駒が先に記載されます。 駒の種類にも順番が決まっています。

飛車、角、金、銀、桂、歩の順で記載されることになっています。

例として下記の持ち駒を表してみましょう 先手持ち駒: 飛車1枚、 歩3枚、銀1枚 後手持ち駒: 角1枚 歩2枚

「R3PSb2p」と表記されることとなります。

手数の表記

手数は何手目かを表しています。

さらにSFENの原案によると、次の手が何手目かという数字も表記する必要があります。ただし、任意局面から開始する場合など、次の手が何手目かという情報に意味がないので、これが必要なものかどうかよくわかりません。将棋所の場合、この数字は必ず1にしています。 将棋所:USIプロトコルとは より引用

ただ将棋所などGUIソフトによっては特に対応せず固定値を返すこともあるようです。

USIの対応

USI(Universal Shogi Interface)とは?

将棋所:USIプロトコルとは より引用

USI(Universal Shogi Interface)プロトコルとは、将棋GUIソフトと思考エンジンが通信をするために、Tord Romstad氏によって考案された通信プロトコルです。USIの原案は、以下で読むことができます。

将棋ソフトと将棋のGUIソフトとの間で通信(会話)するためののものです。具体的には こんな感じです

> usi
< id name naginagi_shogi
< id author NagiNagisa
< usiok 
> isready
< readyok
> usinewgame
> position startpos moves 7g7f
> go btime 0 wtime 0 byoyomi 3000
< bestmove 3c3d
> position startpos moves 7g7f 3c3d 6g6f
・
・
・

ちょっと何言ってるかわからない、って感じだと思うので、日本語での会話風にするとこんな感じです

GUIソフト> usi    // 起動したよー!
将棋ソフト> id name naginagi_shogi  // 私は将棋エンジン naginagi_shogi
将棋ソフト> id author NagiNagisa  // 作者はNagiNagisaだよ!
将棋ソフト> usiok // 自己紹介終わったよ どうぞ(無線会話風)

GUIソフト> isready  // 準備は出来てる?
将棋ソフト> readyok // 準備OKです!

GUIソフト> usinewgame // 対局を開始します

GUIソフト> position startpos moves 7g7f  // 平手初期局面(startpos)からの指し手は 7g7f(76歩)です
GUIソフト> go btime 0 wtime 0 byoyomi 3000  // 先手持ち時間0秒(btime)、後手持ち時間0秒(wtime) 秒読み 3秒(3000ミリ秒) で考えて指してください

将棋ソフト> bestmove 3c3d // 最善手は3c3d(34歩)です

GUIソフト> position startpos moves 7g7f 3c3d 6g6f // 平手初期局面(startpos)からの指し手は 7g7f(76歩) 3c3d(34歩) 6g6f(66歩) です。
GUIソフト> go btime 0 wtime 0 byoyomi 3000  // 先手持ち時間0秒(btime)、後手持ち時間0秒(wtime) 秒読み 3秒(3000ミリ秒) で考えて指してください
・
・
・
// 以下対局が終わるまで続く

最低限将棋ソフトがこの会話をできるようになれば、将棋所や将棋GUIなどの将棋GUIソフトで自分で作った将棋ソフトが動くようになります。

今回使うコマンドをもうちょっとだけ詳しく説明していきます。

usi コマンド

エンジン(将棋ソフト)起動時に最初に送られるコマンド。 GUIが将棋ソフトを登録するときにもこのコマンドで帰ってきた情報を使って登録されます。

このコマンド受け取ったときに、エンジンはidコマンドを返します。

id name <将棋ソフト名>
id author <作者名>

その後最後に エンジンはusiok を返します。

この手続きだけで将棋ソフトの登録だけはできちゃうんですよね。

例えばjavaなら下記の感じで書けばおそらくGUIソフト側に登録するだけなら可能です。

ちなみにUSIのやりとりは標準入出力でやり取りを行います。 javaでいえば、ScannerInputStreamReaderを使って標準入力を受け取り、System.out.print などで標準出力を行うことになります。

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class Sample {
    public static void main(String[] args) {
    boolean running = true;
    BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
    while(running) {
      try {
        Optional<String> OptionalInputstring = Optional.ofNullable(reader.readLine());
        if (OptionalInputstring.isEmpty()) {
            continue;
        }

        String inputString = OptionalInputstring.get().trim();
        String[] command =  inputString.split(" ");

        switch (command[0]) {
            case "usi" -> {
                System.out.println("id name naginagi_shogi");
                System.out.println("id author NagiNagisa");
                System.out.println("usiok");
            }
            case "quit" -> {
                running = false;
            }
        }
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
    }
}

isready

対局開始前にGUI側から送られるコマンドです。 なにかエンジン側で対局する準備の処理があればここで行うのでしょうけど、特になければ何もせずに単に"readyok"と返せばよいだけです。

実際今回作ったプログラムも今のとこはここでは何もしていません。

usinewgame

対局開始時にGUI側から送られます。 なにかエンジン側で対局開始時の処理があればここで行えます 特になければ何もせずに単に"usinewgame"と返せばよいだけです。

実際今回作ったプログラムも今のとこはここでは何もしていません。

position

これがとても大事なコマンド。 思考開始局面をエンジン側に知らせるためのコマンド。

思考開始局面をエンジンに知らせるためのコマンドです。エンジンに思考を開始させる場合、positionコマンドで思考開始局面を送り、それに続けてgoコマンドを送って思考開始を命令することになります。

position <sfen文字列 または startpos> moves <1手目の指して> <2手目の指して> .... <N手目の指して>

初期平手局面についてのみ、sfen文字列の代わりに「startpos」が使われます。

sfenに関しては前述のとおりなのでそちらを参照してください。

moves 以降は sfenで指定した局面からの指し手です。

指し手が進むたびにmovesの指しても増えていくので、ここはどんどん長くなっていきます。

ステートレスなリクエストになっていますね。

指し手の読み方

例えば初期局面から7六歩と動かす場合は 「7g7f」 と表記されます。

7gの地点にある駒を7fに移動するという意味になります。

数字の部分は筋を表してアルファベットのところは段を表し下記のように対応付けられています。

1段目: a 2段目: b 3段目: c 4段目: d 5段目: e 6段目: f 7段目: g 8段目: h 9段目: 1

go

goコマンドが送られると、positionコマンドで指定された局面から思考を開始します。

このgoコマンドの後に続くオプションで、思考時間などのオプションも突くのですが、一旦即時レスポンスを返すようにしているので、最低限の実装では、goコマンドが来たら、 指し手を即時返すようにしておけば動きます。

指し手を返すのはbestmove コマンドでエンジン側からGUIに返却します

bestmove

エンジン側からGUIに向けて返すコマンドです。

探索を終了して、この局面で最適な指し手を返却します。

bestmove <指して表記>

bestmove 3c3d

基本的にgoコマンドに対応したコマンドで、探索終了したときにこのコマンドを送ります。

実装

前回も紹介したこちらのクリーンアーキテクチャの図に近い形で実装しています。 実際には、今回はThreadつかったり、非同期にしないといけない場面があったりするのでイベントでやり取りするようにしています。 ちなみに、今回はイベントでのやり取りをやりやすくしたり、DIなど効率的な開発をするためにプレゼンテーション層とアプリケーション層ではSpringBootを使っています。

画像は Clean Coder Blog より引用

InputPortの役割のGameManagerはこんな感じの定義

public interface GameManager {
    void init(InitEvent initEvent);
    void readyGame(ReadyGameEvent readyGameEvent);
    void startGame(StartGameEvent startGameEvent);
    void stopGame(StopGameEvent stopGameEvent);
    void startSearch(StartSearchEvent startSearchEvent);
    void stopSearch(StopSearchEvent stopSearchEvent);
    void setBoard(SetBoardEvent setBoardEvent);
}

InputPortの実装はアプリケーション層の方で実装を書きます。ここが図でいうInteractorの役割になります。

実装はこうなります。

@Service
public class GameManagerImpl implements GameManager {
    private final GameEventPublisher gameEventPublisher;
    private SearchService searchService;

    public GameManagerImpl(GameEventPublisher gameEventPublisher) {
        this.gameEventPublisher = gameEventPublisher;
    }

    @Override
    @EventListener
    public void init(InitEvent initEvent) {
        // 初期化処理を行う
        // 現状は特に何もなし
        gameEventPublisher.publishDoneInitEvent();
    }

    @Override
    @EventListener
    public void readyGame(ReadyGameEvent event) {
        // ゲームの準備の処理を行う
        // 現状は特に何もなし
        gameEventPublisher.publishDoneReadyGameEvent();
    }

    /**
     * ゲームを開始する(初期化をする)
     */
    @Override
    @EventListener
    public void startGame(StartGameEvent startGameEvent) {
        // ゲーム開始の処理をする
        // 現状は特に何も処理は無し
        gameEventPublisher.publishDoneStartGameEvent();

    }

    /**
     * ゲームを終了する
     */
    @Override
    @EventListener
    public void stopGame(StopGameEvent stopGameEvent) {
        // ゲーム終了の処理をする
        // 現状は特に何も処理は無し
        gameEventPublisher.publishDoneStopGameEvent();
    }

    /**
     * 探索を行うスレッド開始する
     */
    @Override
    @EventListener
    public void startSearch(StartSearchEvent event) {
        searchService = new SearchServiceImpl(board, gameEventPublisher);
        searchService.start();
        gameEventPublisher.publishDoneStartSearchEvent();
    }

    /**
     * 探索を終了する
     */
    @Override
    @EventListener
    public void stopSearch(StopSearchEvent event) {
        searchService.stopSearch();
        try {
            searchService.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        gameEventPublisher.publishDoneStopSearchEvent();
    }

    /**
     * ボードの初期化を行う
     */
    @Override
    @EventListener
    public void setBoard(SetBoardEvent event) {
        this.board = event.getBoard();
        gameEventPublisher.publishDoneSetBoardEvent();
    }
}

OutputPortの役割のOutputPresenterはこんな感じの定義

public interface OutputPresenter {
    void init(DoneInitEvent doneInitEvent);
    void startGame(DoneStartGameEvent doneStartGameEvent);
    void readyGame(DoneReadyGameEvent doneReadyGameEvent);
    void search(DoneSearchEvent doneComleteSearchEvent);
    void startSearch(DoneStartSearchEvent doneStartSearchEvent);
    void stopSearch(DoneStopSearchEvent doneStopSearchEvent);
    void setBoard(DoneSetBoardEvent doneSetBoardEvent);
}

OutputPortの実装はプレゼンテーション層に書きます。

@Component
public class OutputPresenterImpl implements OutputPresenter {

    @Override
    @EventListener
    public void init(DoneInitEvent doneInitEvent) {
        System.out.println("id name naginagi_shogi");
        System.out.println("id author NagiNagisa");
        System.out.println("usiok");
    }

    @Override
    @EventListener
    public void startGame(DoneStartGameEvent doneStartGameEvent) {
    }

    @Override
    @EventListener
    public void readyGame(DoneReadyGameEvent doneReadyGameEvent) {
        System.out.println("readyok");
    }

    @Override
    @EventListener
    public void search(DoneSearchEvent doneComleteSearchEvent) {
        Sashite bestSashite = doneComleteSearchEvent.getBestSashite();
        System.out.println("bestmove " + bestSashite.toUsiMoveString());
    }

    @Override
    @EventListener
    public void startSearch(DoneStartSearchEvent doneStartSearchEvent) {
    }

    @Override
    @EventListener
    public void stopSearch(DoneStopSearchEvent doneStopSearchEvent) {
    }

    @Override
    @EventListener
    public void setBoard(DoneSetBoardEvent doneSetBoardEvent) {
    }
}

標準入力から、イベントを発行するスレッドを定義します

@Component
public class InputControllerThread extends Thread {
    private boolean runnning = true;

    private InputStreamReader in;
    private BufferedReader bufferRead;
    private final GameEventPublisher gameEventPublisher;

    private InputControllerThread(GameEventPublisher gameEventPublisher) {
        this.gameEventPublisher = gameEventPublisher;
    }

    @PostConstruct
    private void init(){
        in = new InputStreamReader(System.in);
        bufferRead = new BufferedReader(in);
    }

    @Override
    public void run() {
        while(runnning) {
            try {
                Optional<String> OptionalInputstring = Optional.ofNullable(bufferRead.readLine());
                if (OptionalInputstring.isEmpty()) {
                    continue;
                }
                String inputString = OptionalInputstring.get().trim();
                String[] command =  inputString.split(" ");

                switch (command[0]) {
                    case "usi" -> {
                        gameEventPublisher.publishInitEvent();
                    }
                    case "isready" -> {
                        gameEventPublisher.publishReadyGameEvent();
                    }
                    case "usinewgame" -> {
                        // System.out.println("対局開始");
                        gameEventPublisher.publishStartGameEvent();
                    }
                    case "position" -> {
                        String[] moves;
                        int movesIndex;
                        Board board;
                        BoardBuilder boardBuilder = new BoardBuilder();
                        if (command[1].equals("sfen")) {
                            movesIndex = 6;
                        } else if (command[1].equals("startpos")) {
                            movesIndex = 2;
                            boardBuilder.setHiratePiece();

                        } else {
                            System.out.println("invalid position command");
                            continue;
                        }
                        board = boardBuilder.build();
                        // movesが存在しないときは初期配置を表すので、初期配置の盤をセットする
                        if (command.length == movesIndex) {

                            gameEventPublisher.publishSetBoardEvent(board);
                            continue;
                        }
                        if (!command[movesIndex].equals("moves")) {
                            System.out.println("invalid position command. moves not exists");
                            continue;
                        }
                        movesIndex++;
                        // usiStringからたどって現局面の盤を生成
                        for (; movesIndex < command.length; movesIndex++) {
                            Sashite sashite = SashiteFactory.create(command[movesIndex]);
                            board = sashite.execute(board);
                        }
                        // 現在の局面をセットするイベントを発行
                        gameEventPublisher.publishSetBoardEvent(board);
                    }
                    case "go" -> {
                        boolean ponder = false;
                        int btime = 0;
                        int wtime = 0;
                        int byoyomi = 0;
                        int binc = 0;
                        int winc = 0;
                        boolean infinite = false;
                        for (int i = 1; i < command.length ; i++) {
                            switch (command[i]) {
                                case "ponder" -> {
                                    ponder = true;
                                }
                                case "btime" -> {
                                    btime = Integer.valueOf(command[++i]);
                                }
                                case "wtime" -> {
                                    wtime = Integer.valueOf(command[++i]);
                                }
                                case "byoyomi" -> {
                                    byoyomi = Integer.valueOf(command[++i]);
                                }
                                case "binc" -> {
                                    binc = Integer.valueOf(command[++i]);
                                }
                                case "winc" -> {
                                    winc = Integer.valueOf(command[++i]);
                                }
                                case "infinite" -> {
                                    infinite = true;
                                }
                                default -> {
                                    System.out.println("invalid go option.");
                                }
                            }

                        }
                        // 時間管理のインスタンスを生成し渡す
                        // 時間間利用の別スレッドを作るアプローチもあるかもしれない
                        Time time = Time.of(btime, wtime, byoyomi, binc, winc, infinite);
                        gameEventPublisher.publishStartSearchEvent(time);
                    }
                    case "stop" -> {
                        // 探索を停止して指してを返すイベントを発火
                        gameEventPublisher.publishStopSearchEvent();
                    }
                    case "ponderhit" -> {
                        // 一旦使わないので処理は無し
                    }
                    case "quit" -> {
                        runnning = false;
                        System.out.println("quit shogi engine");
                        // 停止イベント発行
                        gameEventPublisher.publishStopSearchEvent();
                        gameEventPublisher.publishStopGameEvent();
                    }
                    case "gameover" -> {
                        // 対局結果
                        if (command[1].equals("win")) {
                            System.out.println("勝利");
                        }
                        if (command[1].equals("lose")) {
                            System.out.println("敗退");
                        }
                        if (command[1].equals("draw")) {
                            System.out.println("引き分け");
                        }
                        runnning = false;
                        System.out.println("quit shogi engine");
                        // 停止イベント発行
                        gameEventPublisher.publishStopSearchEvent();
                        gameEventPublisher.publishStopGameEvent();
                    }
                }

            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

処理の流れのシーケンス図っぽいものを書くことこんな感じです。 赤いところはThreadのループだと思ってください。

合法手のみ指すように修正

指し手を反則手を指さなくしました。 今まで王手放置などをしていて対局が終わっていたのですが、これで今回はちゃんと詰みまで指すようになってだいぶそれっぽい動きになってきましたね。

この実装は結構簡単で、反則手を含む候補手のなかから1手動かしてみて、 その局面で王手がかかっているかどうかを見て、王手がかかっていたら王手放置などの反則手なので、候補手からこの手を取り除く。 ということをやればよいわけです。

    public Sashite search(Board board) {
        Sashite bestMove;

        List<Sashite> candidateMoveList = board.candidateMoveList(); // 反則手を含む候補手の取得
        List<Sashite> legalMoveList = new ArrayList<>();  // 合法手をこのリストに入れていく

        // 反則手になる手があったら省く
        for (Sashite sashite: candidateMoveList) {
            Board nextBoard = sashite.execute(board);   // 1手先の局面を作る
            if (nextBoard.isCheck(nextBoard.nextTeban())) {  // 王手がかかってる状態だったらその手は反則手
                continue;
            }
            legalMoveList.add(sashite);  // 王手がかかっていない状態ならその手は合法手として加える
        }

        if (legalMoveList.isEmpty()) {
            // 合法手が存在しない、投了の手を返す
            return Resign.of();
        }

        // 合法手の中からランダムで指し手を選択して返す
        int randomIndex = new Random().nextInt(legalMoveList.size());
        bestMove = legalMoveList.get(randomIndex);

        return bestMove;
    }

探索部でやらずに、局面を見て、この駒はピンされてるからこっちにしか動けない、とか、効きがあるから玉はこっちに動けないとか考えることもできますが、 王手チェックの処理だけ書けばいいこっちの実装のほうが楽ではありますね。

計算量的には不利かもしれませんが、高速な強いソフトを目指しているわけではないので、シンプルな方を選んで作っていってます。

ピンのチェックとか後々必要になるかもしれませんが、それはその時に作ります。

これから取り組むこと

これから3週間目(week3)で取り組むことについて

  • 評価関数の作成

探索部を作るのを先にしようかと思いましたが、評価関数がないと困るので先に評価関数を作ることにします! 探索部と、評価関数が一番難しそうな気がするので、この辺は時間かかるかもしれませんが、じっくりやっていきたいと思います

参考リンク

参考にしたサイト

2020-10-22 18:00:00

V-C級月組5局目 十六夜桜花 VS 凪なぎさ (四間飛車 VS 四間飛車 相振り飛車) 感想

はいどうも、頭痛系VTuberの凪なぎさです(´-﹏-`;) V-C級月組5局目 十六夜桜花 VS 凪なぎさの感想について記載します。 戦型としては四間飛車 VS 四間飛車の相振り飛車の戦いとなりました。 本リーグの私の最終対局です。

結果から言ってしまうと本対局では私の勝利でリーグ成績としては2勝1敗という成績となりました。

目次

V名人戦って?

Vtuberの、Vtuberによる、Vtuberのため棋戦、それがV名人戦 簡単に説明すると、将棋がさせるVtuberの将棋棋戦です。 棋力帯に応じて現在はA級、B級、C級と3つのクラスにクラス分けされています。

大まかな棋力帯としては下記のような感じです。

  • A級: 2段以上
  • B級: 2級 〜 2段
  • C級: 2級以下

最上位のリーグの優勝者がV名人への挑戦権を得て番勝負を行います。

詳しくは公式の規定を参照ください。 V名人戦 規定(2020.08.29).pdf - Google ドライブ

現在(2020年10月22日時点)では第二期V名人戦が開幕しています。

公式サイトもあるので参照ください。 V名人戦公式サイト

参加者一覧や、各参加者のYoutubeチャンネルなどは過去記事にて私のブログでもまとめています。 第2期V名人戦が開幕、参加者とその組分けは? | なぎなぎブログ

中継配信 アーカイブ

本対局の様子はこちらのアーカイブから確認いただけます。

本局の棋譜

81dojoを使って対局をしています。 棋譜はこちらから参照してください。

棋譜再生 (NagisaNagi 対 izaiza_izayoi) / 81道場 Webシステム

感想とポイント

対局についての感想や、対局時に考えていたことについて書いていきます。 ちなみにこの手のほうが良いなどは上げればきりがないので、ここでは言及しません。 あくまで対局中に考えていたこと、思っていたことと、流れが変わった場面等を記載していきます。

対局前

対局の日はあまり体調が良くなく、ちょっと頭痛がしてました。 なのであまり読まなくていいような戦型にしたいという気持ちがありました。

戦型

戦型は四間飛車 対 四間飛車 の相振り飛車の形になりました。

最近は居飛車ばかり指しているのですが、割と序盤から考えることが多かったり気をつけなきゃいけないことが多かったりすると思うので、今回は四間飛車を選びました。 四間飛車選んだ理由は、四間飛車ならほとんどの場合で特に考えずに組めるからです。

相手がノーマル四間飛車やってくることほぼ確定であれば46銀左急戦とかも慣れている(得意戦法も46銀左でエントリーしてた)ので選択肢としてはあったのですが、対戦相手の十六夜桜花さんは振り飛車党というのはわかっていましたが、ノーマル四間飛車、ノーマル三間飛車をせずに、早石田とかだと私は慣れてない展開になって頭痛が痛くなってしまいそうな展開も考えられたので居飛車を避けました。 あとは、1局目、2局目も居飛車だったので、ちょっと振ってみてもいいかなていう気持ちもありました

序盤

四間飛車で美濃囲うところまではとにかく何も考えずに組めたのは作戦通りです。 相振りなのに端歩付きこしちゃったのは居飛車で対抗系のときは端受けてこないときはいつも付きこすようにしてるのでその癖が出てしまって想定外でしたが(゜゜)

あといつも相振り飛車のときは、この四間飛車の手筋で2筋に回るのやってるんですが、今回相手の銀上がりが早かったのでできなかったですね。

本当はこういう展開にしたかったですね。

別に本局の展開も悪くはないと思いますが、慣れてない感じになってしまったかもしれません。

中盤

銀を打たされてやや指しにくい感じに

中盤と言えるのかわからないですが、駒がぶつかって銀交換となった場面

このあたりやり取りは、こっちだけ先に銀を打たないといけなくなってしまってちょっと悪いかなと思っていました。

65歩突くと角交換になるので、角と銀を両方持ち駒になると相手の指し手の選択肢も増えそうで怖かったので、なかなかこの後65歩と突くこともできませんでした。 なので飛車引かれたあとに、65銀と出たのですが疑問手だったみたいです。 65銀は形悪いですが、1歩得ですし、56銀と戻ればまぁ良いかなと思っていました。

このあとの展開として、感想戦でも少し触れていましたが、先手の私の65銀に対して後手側は24歩から23飛車と2筋に回る展開をされるとこちらが厳しそうでした。

うまく咎められた55銀

55銀打ちと駒をぶつけてきた局面

この瞬間相手の角が浮いているので,65歩や55銀(同銀) 同歩 65歩という手がぱっと浮かびました。

このあたりすぐ浮かぶようになったのは成長を感じますね。

すぐに65歩つくのは、56銀取られたときに角は抜けますが、56銀の位置が嫌だったりなにか技がかかるといけないと思い、55銀 同歩 65歩の手順としました。 そのほうが局面はシンプルだと思うので。頭痛のときはシンプルに!

終盤

この局面で良い必至をかける手がありましたが、ちょっと自信がなくてさせませんでした。

ちなみにここで71角成と切ってしまうのが必至となります。

28銀としたりしても 同馬 同玉 74桂 71玉 82金 といった形で詰みます

ただ対局中はちゃんと読みきれず、詰みそこねてここで逆転されるのも怖いので、本局では11銀と香車を取って今回は安全勝ちを狙いに行きました。

この後は狙い通り安全を維持してなんとか勝つことができました。

全体を通して

ソフトでの解析結果を置いておきます。

序盤で疑問手2つでしたが、悪手はなかったので良かったかなと思います。

あと、今回は時間を序盤の方で使い切らなかったのは良かったかもしれません。

いつものなら序盤で時間を使い切ってしまうのですが、四間飛車という割と考えなくても組みやすい戦法の選択や、 頭痛なのであまり深く読まずシンプルにわかりやすく進めていくという意識があって、その結果割とうまく時間も使えてた気がします。

これがなぎなぎ流頭痛戦法です( ・´ー・`)

さいごに

対局者の十六夜桜花さん、中継、聞き手のすず白ラパニスさん、世良祭さん、見に来てくれた皆様ありがとうございました。

本体局でV名人戦の私の対局は全て終わり、成績としては2勝1敗となりました。

V名人戦自体はまだまだ続いているのでこれからは観戦モードで楽しんでいこうと思います。

2020-10-22 08:00:00

将棋ソフト開発 Week1 ~ 将棋のコア部分を作る ~

プログラミングの勉強がてらに、将棋ソフト開発をしています。0週目は作るプログラムを方針を作成しましたが、1週目が終わり進捗報告と、やったこと、これから取り組むことについて記載していきたいと思います。 1週目ではリポジトリやパッケージ構成の準備と将棋のコア部分である各駒や盤の実装や、ルールの実装を取り組みました。

目次

進捗報告

一旦ランダムに動くところまで作りました。将棋のプレイを管理するクラスはまだ書いてないのでテストコードで無理やり実装して動かしているだけです。

ちょろっと書いてあるプログラム自体は色々この後いじってるので、後述の解説?とは違うところがあります。

1週目でやったこと

  • 将棋のコア部分の作成
    • パッケージ、リポジトリ構成決め
    • 駒の作成
    • 盤の作成
    • 移動のルールの作成

駒や盤といってもプログラム的に作成してるだけなので、実際に目に見えるものは作っていません。 一応Debugようにコンソール上で表示するようにはしました。

方針の変更

若干方針を変更しまして、拡張性の高いプログラムを作ると言っていましたが、一旦汎用的なものではなく普通の9x9の将棋をわかりやすく作ることにします。 あまりに汎用的なものを作るともはやボードゲームエンジンになってしまうので、ソレは非常に作るのは大変です。まずは動くものを作ることを優先!

パッケージ、リポジトリ構成決め

gradleのマルチプロジェクトにします。 ちなみに言語はJavaを使って書き始めてしまいました。今覚えばKotlinにしておけばよかったと思ってます。 時間があればKotlinで書き直すかもしれません。

3つのプロジェクトを包含していて、それぞれ役割毎に別リポジトリにしています。

  • shogi-presentation-cli
    • プレゼンテーション層
    • 末尾にcliとしているのは一旦コマンドラインでの入出力を作成するため.
    • 今後cliではなくWebAPIのインタフェースなども作る可能性もあるが、このプレゼンテーション層のみ変更すればよいです。
  • shogi-application // アプリケーション(ユースケース)層
  • shogi-core // ドメイン層

この分け方はクリーンアーキテクチャ(Clean Architecture)やオニオンアーキテクチャを意識した分け方にしています。

画像はClean Coder Blog より引用

今の所インフラ層はないですが、今後作るアプリケーションで棋譜保存が必要になるとか、外部との通信が発生するとかとなれば作ればよいです。

現状作るものでcliのアプリを想定して下記のような構成を予定しています。

.
|-- gradle
|   `-- wrapper
|-- shogi-application // submoduleで別リポジトリ, アプリケーション層のコードを格納する
|   |-- gradle
|   |   `-- wrapper
|   `-- src
|       |-- main // アプリケーション層の実装コード
|       |   |-- java
|       |   |   `-- com
|       |   |       `-- naginagisa
|       |   |           `-- shogi
|       |   |               `-- application
|       |   |                   |-- manager
|       |   |                   `-- service
|       |   `-- resources
|       `-- test // アプリケーション層のテストコード
|           `-- java
|               `-- com
|                   `-- naginagisa
|                       `-- shogi
|                           `-- shogiapplication
|-- shogi-core  // submoduleで別リポジトリ, ドメイン層のコードを格納する
|   |-- gradle
|   |   `-- wrapper
|   `-- src // ドメイン層の実装コード
|       |-- main
|       |   `-- java
|       |       `-- com
|       |           `-- naginagisa
|       |               `-- shogi
|       |                   `-- core
|       |                       `-- domain
|       |                           |-- model // ドメインモデル
|       |                           `-- service // ドメインサービス 必要ない場合もある
|       `-- test // テストコード
|           `-- java
|               `-- com
|                   `-- naginagisa
|                       `-- shogi
|                           `-- core
|                               `-- domain 
|                                   |-- model
|                                   `-- service
`-- shogi-presentation-cli // submoduleで別リポジトリ, プレゼンテーション層のコードを格納する
    |-- gradle
    |   `-- wrapper
    `-- src
        |-- main // プレゼンテーション層の実装コード
        |   |-- java
        |   |   `-- com
        |   |       `-- naginagisa
        |   |           `-- shogi
        |   |               `-- shogipresentationcli
        |   |                   |-- presenter
        |   |                   `-- controller
        |   `-- resources
        `-- test // プレゼンテーション層のテストコード
            `-- java
                `-- com
                    `-- naginagisa
                        `-- shogi
                            `-- shogipresentationcli

一旦仮おきで、細かくはもっと分けるかもしれませんし、若干の構成変更はするかもしれません。 ただこの層で分けた、マルチプロジェクトの構成は変わらない予定です。

week1で今作ってるのはshogi-core(ドメイン層)の部分です。 submoduleにしてあるので、実際にはshogi-coreのリポジトリ単体で開発をしていけます。

駒の作成

本プログラムは高速なものを作るつもりは一切ないので、bitboardとかは使いません。コンピュータ将棋は勉強したてであまり良くわかっていませんし。 余談ですが、相当速度は犠牲にしているので、探索や学習で性能が出なくて使い物になないかだけが不安です。

domain.model.piece以下に駒関連のクラスを生成します。

pieceType側(enum)に全てロジック持つこともできそうですが、クラスで分けたほうが素直そうなので今回はクラスで分けてます。

各駒クラスはイミュータブルなものとしています。スレッドセーフになりますし、意図していないところで変更の影響を受けないようにするためです。 毎回オブジェクトを生成するのはコストではありますが、 将棋は81マスで、先手後手と駒の種類があるとして1つのコマあたりたかだか 81*2 で162のオブジェクトの生成で済みます。この程度であれば事前に生成しておく、もしくはキャッシュしておくなどすればそれほどコストはかからないかと思います。

pieceTypeはこんな感じ。 javaの場合はenumの中に振る舞いをかけてしまうのでそっちに各駒のルールなどのロジックを書いてもよいですね。

public enum PieceType {
    FU,
    KYO,
    KEIMA,
    GIN,
    KIN,
    KAKU,
    HISYA,
    TOKIN,
    NARI_KYO,
    NARI_KEI,
    NARI_GIN,
    UMA,
    RYU,
    GYOKU
}

今はクラス内で自分自身が動ける箇所を求めるメソッドを持っています(legalMoveList). 中でさらにメソッドに分けたりはしていますがやっていることは下記のことです

  • 自分の持つ効き(Effect)方向に探索して動ける箇所(Position)を取得
  • 歩は1段目(後手の場合は9段目)には存在できないなどのルール上での動ける箇所(動けない箇所)の取得

最終的に動けるマスは、これらで求めたPositionの集合の積集合や差集合などの集合演算で求めています。 ルールとかも別クラスに切り出したいですが一旦ベタ書きです Effectも将棋はたまたま効きと駒の動きが一緒ではありますが、本来動きと効きは別物だとも考えられるので分けておきたくもあります。チェスとかだと実際に効きと動きが違う駒もありますしね。 とはいえそういう変則将棋をもし作るとなったときに考えれば良いので、一旦効きと移動箇所は一緒のものにしておきます。

効きに関しては次のように定義しています。 enumですが、Javaの場合は振る舞いを持てるので盤上の他の駒を考慮しない効きのPositionのリストを返すpositionListというメソッドをつけています。

各駒クラスの、legalMoveListの実装で1つ気になっているのは王手放置などの反則手を含めるかどうか。 ここでピンや、王手放置などの手を省くもしくは、後述の盤(Board)側で省くのも良いのですが、探索時に次の局面を作ってその局面が合法な局面かどうかを判断するほうが簡単そうな気がします。 っというわけで一旦ここは反則手ありの手を生成するようにしています。 その場合メソッド名は要検討ではありますが.

盤の作成

盤関連のクラス図は次のようにしています

BoardもImmutableにしたいですが、盤には駒を追加していったりするので、一発で生成するのは引数が多くなってしまったり、例えば駒を追加するときに生成側で駒のコレクションを生成したりと大変になってしまいます。 なのでBuilderクラスを用意して生成することにしています。

マス目の表現

Squareはマス目を表します。将棋盤であれば9x9の配列で表しています。 この二次元配列も別クラスに切り分けたほうがよさそうですが一旦、このままで。後々リファクタリングするかも。

持ち駒の表現

CapturedPiecesは持ち駒を表します。 単純Mapで歩が○枚、金が▲枚みたいな感じで持っているだけですね。 持ち駒は打つことができるので、uchiteListメソッドで打つ指し手のリストを返すように実装しています。

ボード上でのすべての指して

すべての指してはlegalMoveListで求めます。 やっていることは、各コマのlegalMoveListメソッドの返り値と、持ち駒のuchiteListをすべてマージしています。

    public List<Move> legalMoveList() {
        List<Move> legalMoveList = new ArrayList<>();
        legalMoveList.addAll(pieceSet(teban).stream().flatMap(p-> p.legalMoveList(this).stream()).collect(Collectors.toList()));
        legalMoveList.addAll(currentCapturedPieces().uchiteList(this));

        return legalMoveList;
    }

pieceSetメソッドは書いてないですが、現在の手番の駒をすべて取得するようなメソッドです。 各駒クラスや、持ち駒クラスにそれぞれの領域での手の生成は任せているので、簡潔にかけます。

moveメソッド

指し手(Move)を作用させた後の盤を返します。 Move自体の説明は後述の指し手の表現を参照してください。 つまり1手指した後の局面を作るメソッドです。

指し手の表現

一旦こうしています。※ダメな実装です

from: 移動元の位置、 to: 移動先の位置, isPut: 駒打ちかどうか putPieceType: 打つ駒の種類 isPromotion: 成るかどうか

駒を打つときはfromはnullになって、 isPutフラグで駒打ちかどうか判断できる。 一見なんとかなりそうですが、 さすがにこのクラスはいろいろ問題点が多いので流石に後で直します。

問題点

  • 呼び出し側でisPutフラグを見て判断しないといけない
  • 駒を打つときはfromがnullになっていることを意識しないといけない。 Optionalなどでチェックを強制できるがあまりよいとは思えない

解決策 駒の移動のアクションと、駒を打つアクションを一緒くたにしてるのが問題な気がします。

といわけで、例えばですが指し手インタフェースを作って、それを実装した駒打ちクラス、駒移動クラスなどと言った形で各アクションごとの実装クラスを分けたほうが良さそうです。

week2で直しておきたい。

やったことのまとめ

全体のクラス図としてはこんな感じになります。 細かい関係の線は複雑になるのであえて省いています。

だいぶ部品は揃ってきたかなーという感じです。

これから取り組むこと

これからweek2で取り組むことについて

  • week1で残した課題の解消
  • 最低限のusiプロトコルの実装

先に探索部分を作ろうかとも思いましたが、さすがにコンソール画面上で将棋動かしているだけではつまらないですし、自分で対局しようにも、コマンド打ちながらというのも大変なので先にUSIプロトコルの実装をすることにしました。

USI(Universal Shogi Interface)プロトコルとは、将棋GUIソフトと思考エンジンが通信をするために、Tord Romstad氏によって考案された通信プロトコルです。USIの原案は、以下で読むことができます。

将棋所:USIプロトコルとは より引用

要は、USIプロトコルを実装すれば、自分で作った将棋ソフトも、将棋所や将棋GUIでエンジンとして登録できるようになるということです。 将棋GUIや将棋所が使えれば、マウスなどGUI操作で自分のソフトを試すことができるのでテストプレイが非常にやりやすく成りますね。

というわけでこちらを優先してweek2は取り組もうと思います。