打ち歩詰めのチェックに比べたら簡単
前回の記事で将棋アプリの静的評価関数内で玉を2枚持っていれば勝ち(玉を取れば勝ち)と判定していて、ステイルメイト=引き分けというチェスのルールに対応出来ないことを書きましたが、ステイルメイトに対応するために2つのやり方を試してみました。
方法1:詰み判定(ゲーム終了判定)処理を追加する
王様が取られていたらゲーム終了とするのではなく、手番と駒の利きと王手(チェック)の有無などの情報を駆使して現時点の局面が詰んでいる(チェックメイト状態)かどうかの判断を含めて評価関数内で評価するやり方です。王様(キング)が取られているかどうかだけを見て判断するより処理は複雑で重い処理になります。こういう場合は先手勝ち、こういう場合は王手放置で後手負け、こういう場合はステイルメイトで引き分け、と評価関数内でいちいち定義していくのでif文、case文だらけになり重たい処理になります。でも、その分先読みの深さを一手から二手省略できるのでメリットも大きいです。
じつは以前にも将棋アプリで思考時間短縮になるかと思い試してみたことがあったのですが、思ったほど効果が上がらなかったのでリリース版には採用しませんでした。それに一つ一つのケースについて勝ち負けの判定をするので漏れが生じてバグが発生し易いのも採用を見送った理由です。
あと、この方法で作ると王様を取る以前の段階で評価しますので、ユーザーに相手玉を取るところまで指し手を進めさせることが出来なくなりますが、チェスの場合もチェックメイト(詰み)の段階でゲームを終えるのが一般的なようなので問題ないと思います。
方法2:一定条件下で追加で先読みする
王様(キング)を二つ持っていれば勝ちという評価関数は変更せずに、先読みの過程でキングがチェックされていなくてキングを動かせない局面の場合には指定されている先読みの深さを無視して、さらに一手余計に先読みするという方法です。以前の記事に書いた打ち歩詰めチェックの方法と同じです。ステイルメイトの局面である可能性がある時は、先読み時に指定する何手先まで読むかを制限する引数を無視してさらに一手読むようにするところがミソです。打ち歩詰めと違って2手読む必要はなく、次にキングが取られるかどうかだけを見ればいいので追加で先読みするのは一手(チェス流に言えば0.5手読み)で済みます。打ち歩詰めチェックよりは楽だし、十分実績のある既存のコードなので新たなバグが発生する可能性も低いと思って、まずこの方法で実装しました。
このサイトによるとステイルメイトの定義は以下のようになっています。
- チェックされていない
- キング以外に動かせる駒がない。
- キングをどこに動かしても取られてしまう。(自殺行為でルール上そのようなマスには移動できない)
条件1.と3.は軽い処理で済みますが、2.の条件をチェックするには結局全ての駒の動きを調べる必要があるので、それをするぐらいならもう一手余分に先読みさせて済まそうという考え方です。条件1.と3.を満たす局面が現れた時だけ条件2.を調べて、勝ち(最大値)か負け(最小値)の評価値が返ってきたら評価値を強制的にDraw(評価値=0)に変更するということです。打ち歩詰め対策の時は評価値に−1を掛けて打ち歩詰めをした方が負けになるようにしましたが、ステイルメイトの場合は評価が逆転するわけではないので0に置き換えるだけでいいと思います。←評価値0にするのは問題ありました、詳細は次の記事参照
とりあえず「方法2」で実装
例1:Bf6のステイルメイトを避ける
(クリックで画像拡大)
前回の記事で挙げた例ではとりあえずステイルメイトを避けるようになりました。但しNf7(Knightをf7に移動する手)がこの局面での最善手かどうかは分かりません。
例2:c8=Qのステイルメイトを避ける
(クリックで画像拡大)
修正前はポーンがc8に移動してQueenに成っていた(=ステイルメイト)のですが、修正後はBishopに成ってステイルメイトを避けるようになりました。以下Kb8,Qb7と進んでチェックメイトで白の勝ちです。
打ち歩詰めやステイルメイトの負担
以下のコードはgithubの将棋アプリの先読み探索部分のコードですが、全コードの25%(青い部分)が打ち歩詰めチェックのためだけのコードになっています。今回のチェスプログラムのコードも同様にステイルメイトチェックのために随分プログラムのレスポンスが悪くなってしまいました。仕方がないこととは言えこの特殊なルールのためにパフォーマンスが低下してしまいます。
think: (board, oppo, limit, preValue, utifudume = null) ->
spare = {}
lastscore = if @turn == Const.FIRST then Const.MIN_VALUE else Const.MAX_VALUE
lastposi = null
lastkoma = null
laststatus = null
score = 0
kinds = []
move_piece = null
utifudume_flg = null
for koma in board.pieces when koma.turn == @turn
if koma.status == Const.Status.MOTIGOMA
continue if koma.kind() in kinds
kinds.push(koma.kind())
for col in [1..board.cols]
for row in [1..board.rows]
dest = (v for v in board.pieces when v.posi? && v.posi[0] == row && v.posi[1] == col)
continue if dest.length != 0
if koma.kind() == 'Fu' && is_utifuOute.call @, board, koma, [row, col]
if board.check_utifudume(koma, [row, col])
utifudume_flg = null
continue
else
utifudume_flg = koma
else
utifudume_flg = null
move_piece = new Piece.Piece(koma.turn, koma.status, koma.posi)
if board.check_move(koma, [row, col])
board.move_capture(koma, [row, col])
result = board.gameover()
if limit > 0 && !result
ret = oppo.think(board, @, limit - 1, lastscore, utifudume_flg)
score = ret[2]
else
score = check_tumi.call @, board
shortCut = false
if (score > lastscore && @turn == Const.FIRST) || (score < lastscore && @turn == Const.SECOND)
spare["koma"] = lastkoma
spare["posi"] = [].concat(lastposi)
spare["score"] = lastscore
spare["status"] = laststatus
lastkoma = koma
lastscore = score
lastposi = [].concat([row, col])
laststatus = koma.status
if ((preValue < score && @turn == Const.FIRST) || (preValue > score && @turn == Const.SECOND))
shortCut = true
koma.turn = move_piece.turn
koma.status = move_piece.status
koma.posi = move_piece.posi
return [lastkoma, lastposi, lastscore, laststatus, spare] if (score >= Const.MAX_VALUE && @turn == Const.FIRST) || (score <= Const.MIN_VALUE && @turn == Const.SECOND)
return [lastkoma, lastposi, lastscore, laststatus, spare] if shortCut
else
for v in eval("Piece." + koma.kind()).getD(koma.turn, koma.status)
buf = [].concat(koma.posi)
loop
break unless ((buf[0] + v.xd in [1..board.cols]) && (buf[1] + v.yd in [1..board.rows]))
promotion = false
buf[0] += v.xd; buf[1] += v.yd
dest = (o for o in board.pieces when o.posi? && o.posi[0] == buf[0] && o.posi[1] == buf[1])
break if dest.length != 0 && dest[0].turn == koma.turn
move_piece = new Piece.Piece(koma.turn, koma.status, koma.posi)
if dest.length != 0
dest_piece = new Piece.Piece(dest[0].turn, dest[0].status, dest[0].posi)
if board.check_move(koma, buf)
promotion = true if board.check_promotion(koma, buf)
board.move_capture(koma, buf)
loop
result = board.gameover()
if utifudume? && !result && dest.length != 0 && dest[0].id == utifudume.id && koma.kind() != 'Ou'
ret = oppo.think(board, @, 0, lastscore, null)
if ret[2] >= Const.MAX_VALUE || ret[2] <= Const.MIN_VALUE
score = ret[2] * -1
else
if limit > 0
ret = oppo.think(board, @, limit - 1, lastscore, null)
score = ret[2]
else
score = check_tumi.call @, board
else
if limit > 0 && !result
ret = oppo.think(board, @, limit - 1, lastscore, null)
score = ret[2]
else
score = check_tumi.call @, board
shortCut = false
if (score > lastscore && @turn == Const.FIRST) || (score < lastscore && @turn == Const.SECOND)
spare["koma"] = lastkoma
spare["posi"] = [].concat(lastposi)
spare["score"] = lastscore
spare["status"] = laststatus
lastkoma = koma
lastscore = score
lastposi = [].concat(buf)
laststatus = koma.status
if ((preValue < score && @turn == Const.FIRST) || (preValue > score && @turn == Const.SECOND))
shortCut = true
# 駒が成れる場合は成ってからもう一度評価する
if promotion
promotion = false
koma.status = Const.Status.URA
else
break
koma.turn = move_piece.turn
koma.status = move_piece.status
koma.posi = move_piece.posi
if dest.length != 0
dest[0].turn = dest_piece.turn
dest[0].status = dest_piece.status
dest[0].posi = dest_piece.posi
return [lastkoma, lastposi, lastscore, laststatus, spare] if (score >= Const.MAX_VALUE && @turn == Const.FIRST) || (score <= Const.MIN_VALUE && @turn == Const.SECOND)
return [lastkoma, lastposi, lastscore, laststatus, spare] if shortCut
break unless (dest.length == 0 && v.series)
return [lastkoma, lastposi, lastscore, laststatus, spare]
is_utifuOute = (board, piece, d_posi) ->
oppo = if piece.turn == Const.FIRST then Const.SECOND else Const.FIRST
oppo_king = (v for v in board.pieces when v.turn == oppo && v.kind() == 'Ou')[0]
buf = [].concat(d_posi)
buf[0] += Piece.Fu.getD(piece.turn, piece.status)[0].xd
buf[1] += Piece.Fu.getD(piece.turn, piece.status)[0].yd
return (oppo_king.posi[0] == buf[0] && oppo_king.posi[1] == buf[1])
今回は方法2でステイルメイト対策をしましたが、方法1のやり方も検討中です。
ステイルメイトも打ち歩詰めもゲームを面白くする要素かも
チェスプログラムのテストをしている際に「引き分けがチェスを面白くしている」という意見を見かけました(質問1に対する回答)。
前回の記事でステイルメイトは合理的でないと貶しましたが、いろいろなケースをテストしている内に「ステイルメイト(引き分け)がチェスを面白くしている」という意見の意味が少し分かってきました。将棋の打ち歩詰め同様ゲームを面白くしている要素なのかもしれません。