Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

特殊ルール

前提知識: ムーブジェネレータ(指し手生成の基本フロー)

このページの要点

  • 打ち歩詰めは「歩を打って王手 → 玉が逃げられない → 取り返せない」の 3 条件をすべて満たす場合のみ禁じ手
  • 二歩判定は 筋マスク & 自歩 Bitboard の交差で判定できる(最も安価な特殊ルールの一つ)
  • 千日手検出は apply_move32() のたびに repetition_counter を差分更新し、is_repetition(threshold) は O(1) 判定
  • 256 手ルールは大会ごとに最大手数が異なる(WCSC: 320, 電竜戦: 512, floodgate: 256)

将棋における特殊ルール(千日手、打ち歩詰め、二歩、ピン判定、入玉宣言)の実装とその注意点について解説します。

王手判定

王の位置から敵駒の攻撃範囲を逆算し、実際の敵駒配置と照合します。

rsshogi では王手判定は Position::is_in_check() で行います(引数なし、手番側の玉への王手を返す)。 王手をかけている駒の集合は pos.checkers() で取得できます(apply_move32() のたびに自動キャッシュ)。

以下は概念的な王手判定のコード(実際には is_in_check() が内部で同様の処理を行います):

// rsshogi の公開 API での使い方
let in_check: bool = pos.is_in_check();
let checkers: Bitboard = pos.checkers();  // 王手している駒の集合

// 特定マスへの攻撃駒を列挙したい場合(attacks.rs)
let occupied = pos.bitboards().occupied();
let attackers: Bitboard = pos.attackers_to(sq, occupied);

Bitboard 実装では、駒種別の攻撃候補を集合として合成し、現在の敵駒配置と照合します。 配列ベースで全敵駒を走査する実装に比べ、分岐とループを抑えやすい構造です。

ピン判定(Pin Detection)

王と敵の遠方駒の間に自駒が 1 つだけある場合、その自駒は「ピン」されています。 上の図では、5二の後手飛車と5五の先手玉の間にある5三の先手銀がピンされています。

pub fn compute_pinned_pieces(position: &Position, king_color: Color) -> Bitboard {
    let king_sq = position.king_square(king_color);
    let bitboards = position.bitboards();
    let self_pieces = bitboards.by_color[king_color];
    let enemy_color = king_color.flip();
    let occupied = bitboards.occupied;

    let mut pinned = Bitboard::EMPTY;

    // 飛車・竜のピン
    let rook_pinners = bitboards.by_piece[ROOK] & bitboards.by_color[enemy_color];
    for pinner_sq in rook_pinners {
        let between = Bitboard::between(king_sq, pinner_sq);
        let blockers = between & occupied;

        // 間に駒が1つだけ && それが自駒
        if blockers.count() == 1 && (blockers & self_pieces).any() {
            pinned |= blockers;
        }
    }

    // 角・馬のピン(同様の処理)
    // 香のピン(同筋の場合のみ)
    // ...

    pinned
}

参照実装の対応実装: blockersForKing / pinners キャッシュ

二歩判定

同じ筋に自分の歩が既に存在するかを判定します。 上の局面では先手の歩が 2 筋と 8 筋を除くすべての筋に存在しているため、二歩ルールにより歩を打てる筋は 2 筋と 8 筋だけです。 黄色ハイライトは、その 2 筋・8 筋の中でも実際に空いていて、先手の歩が成れない一段目でもないマスです。 赤い丸は、既に先手歩があるため二歩で除外される筋を示しています。

pub fn can_drop_pawn(position: &Position, file: File, color: Color) -> bool {
    let bitboards = position.bitboards();
    let pawns = bitboards.by_piece[PAWN] & bitboards.by_color[color];
    let file_mask = Bitboard::file_mask(file);

    // 指定筋に歩がない
    !(pawns & file_mask).any()
}

実装上は、指定筋のマスクと自歩 Bitboard の交差が空かどうかを調べます。

打ち歩詰め判定

歩を打った直後に王手がかかり、かつ王が逃げられない場合は禁じ手です。 上の局面(実テストコードより)では、先手が P*1d(1四に歩を打つ)と後手玉(1三)に王手がかかりますが、後手玉は逃げることも歩を取ることもできないため打ち歩詰めとなり、この手は禁じ手です。

判定アルゴリズム

打ち歩詰め判定(is_legal_drop)は、歩を打つと王手になる前提で、以下の 3 条件を順に確認します。 いずれかで「逃れられる」と分かれば打ち歩詰めではなく、最後まで残れば打ち歩詰め(=違法な歩打ち)です。

  1. 取り返しチェック(ピンの同筋例外つき): 打った歩を相手が取り返せるか。 取り返せる敵駒は attackers_to_pawn(them, to)(玉・香・歩を除く)で列挙し、 そのうちピンされていない駒、または「歩と同じ筋」方向にピンされている駒(その方向には取れる)があれば取り返せる。
  2. 玉の逃げ場チェック: 歩を打った後の盤面で、玉が利きの及ばないマスへ逃げられるか。
  3. 上記いずれにも当てはまらなければ打ち歩詰め。

「合駒」のステップは存在しません。歩を打つ手に対して合駒で受けることはできないためです。

実際のソースコードは is_legal_drop メソッドです:

    pub(crate) fn is_legal_drop(&self, to: Square) -> bool {
        use crate::board::attack_tables::KING_ATTACKS;

        let us = self.turn();
        let them = us.flip();

        if !self.is_attacked_by(to, us) {
            return true;
        }

        let attackers = self.attackers_to_pawn(them, to);
        let pinned = self.blockers_for_king(them);
        let file_bb = Bitboard::file_mask(to.file());
        if !attackers.and(pinned.not().or(file_bb)).is_empty() {
            return true;
        }

        let king_sq = self.king_square(them);
        if king_sq.is_none() {
            return true;
        }
        let mut escape_bb = KING_ATTACKS[king_sq].and_not(self.bitboards().color_pieces(them));
        escape_bb.clear(to);

        let mut occupied = self.bitboards().occupied();
        occupied.set(to);

        while let Some(king_to) = escape_bb.pop_lsb() {
            if !self.attacked_by_color_fast(us, king_to, occupied) {
                return true;
            }
        }

        false
    }

ソースコード

ピン駒の同筋例外の図解:

例1) 斜めのピン        例2) 横のピン         例3) 縦のピン(例外)
^玉 ^角  飛            ^玉                   ^玉
 歩                     歩 ^飛                 歩
                                 角           ^飛
                                               香
  • 例1, 2: ピン駒が玉頭の歩を取る動きは、ピン方向と異なるため合法
  • 例3: ピン駒(飛)が玉頭の歩を取る動きは、ピン方向と一致するため合法(file_bb で例外処理)

この判定は単純な二歩判定より重く、取り返しチェック、ピン判定、玉の逃げ場判定を組み合わせます。 実装の要点は、歩を打った後の局面を完全に探索するのではなく、打ち歩詰めに必要な逃れ手だけを確認することです。

キャッシュ戦略

王手・ピン・王手候補升などは StateInfo 内の TacticalCachepub(crate)、外部からは アクセサ経由で参照)にキャッシュされます。概念的には次の情報を保持します。

// 概念図(実体は state_info.rs の TacticalCache)
struct TacticalCache {
    checkers: Bitboard,                       // 王手している駒
    pinners: [Bitboard; 2],                   // ピンを掛けている駒(先後別)
    blockers_for_king: [Bitboard; 2],         // 玉へのラインを遮る駒(=ピンされうる駒、先後別)
    check_squares: CheckSquares,              // 駒種別の王手候補升(CheckSquares = [Bitboard; 9] の newtype)
}

公開 API では pos.checkers() / pos.blockers_for_king(color) などのアクセサ経由で参照します。 これらをキャッシュすることで、合法手生成時の重複計算を回避できます。

千日手(Repetition)

基本ルール

同一局面が4回出現した場合、千日手として引き分けとなります。 ただし、連続王手の千日手は反則負けとなります。

千日手の状態は RepetitionState enum で表現されます:

/// 千日手の状態を表す enum
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RepetitionState {
    /// 千日手でない
    #[default]
    None,
    /// 連続王手の千日手で相手が負け(自分の勝ち)
    Win,
    /// 連続王手の千日手で自分が負け
    Lose,
    /// 通常の千日手(引き分け)
    Draw,
    /// 優等局面(持ち駒が過去局面より優れている)
    Superior,
    /// 劣等局面(持ち駒が過去局面より劣っている)
    Inferior,
}

ソースコード

実装パターン

千日手の検出には、StateInfo スタックに保存されたハッシュ値を使用します。 実際の実装では、do_move のたびにインクリメンタルに千日手カウンタを更新し、 is_repetition は単にカウンタを閾値と比較するだけです:

    /// 千日手判定。
    ///
    /// 現在の局面が `threshold` 回以上繰り返されていれば `true` を返す。
    /// `threshold` は千日手と判定する繰り返し回数(通常は 3)。
    #[must_use]
    pub fn is_repetition(&self, threshold: u8) -> bool {
        if threshold == 0 {
            return true;
        }

        let stack = self.state_stack();
        stack.hot(self.st_index).repetition_counter >= i32::from(threshold)
    }

    /// 千日手の詳細な状態を判定(探索用、ply 制約付き)
    ///
    /// 指定 ply 以内の千日手状態を判定する。
    /// 同一局面の繰り返しを検出し、連続王手の千日手や優等/劣等局面も判定する。
    #[must_use]
    pub fn repetition_state_with_ply(&self, ply: usize) -> RepetitionState {
        let stack = self.state_stack();
        // 千日手判定の前提条件:repetition_counter >= 3(4回目の出現)
        // 4回目の同一局面の場合は強制的に千日手となるため、ply に関わらず検出
        if stack.hot(self.st_index).repetition_counter >= 3 {
            return self.repetition_state();
        }

        // 遡り可能な手数を計算
        // null move より前と保持範囲の外側は走査しない。
        let plies_from_null = stack.hot(self.st_index).plies_from_null;
        let ply_limit = ply.min(plies_from_null as usize).min(max_repetition_ply() as usize);

        // 少なくとも4手かけないと千日手にはならない
        if ply_limit < 4 {
            return RepetitionState::None;
        }

        let current_state = stack.hot(self.st_index);
        let current_hand = current_state.hand;
        let current_continuous_check = current_state.continuous_check;
        let current_board_key = self.board_key;

        // スタックを遡って同一局面を検索
        // 4手前から、2手ずつ遡る(同一局面に戻るためには偶数手必要)
        let mut distance = 1usize;
        while distance <= ply_limit && distance <= self.st_index {
            let idx = self.st_index - distance;
            let state = stack.hot(idx);

            // 4手以上遡り、かつ偶数手の場合のみチェック
            if distance >= 4 && distance.is_multiple_of(2) {
                if state.board_key == current_board_key {
                    if state.hand == current_hand {
                        let us = self.turn();
                        let repetition_type =
                            if distance <= usize::from(current_continuous_check[us.to_index()]) {
                                RepetitionState::Lose
                            } else if distance
                                <= usize::from(current_continuous_check[us.flip().to_index()])
                            {
                                RepetitionState::Win
                            } else {
                                RepetitionState::Draw
                            };

                        if state.repetition_times > 0 && repetition_type != state.repetition_type {
                            return RepetitionState::Draw;
                        }

                        return repetition_type;
                    }

                    if Hand::is_equal_or_superior(current_hand, state.hand) {
                        return RepetitionState::Superior;
                    }

                    if Hand::is_equal_or_superior(state.hand, current_hand) {
                        return RepetitionState::Inferior;
                    }
                }

                // plies_from_null == 0 に到達したら終了
                if state.plies_from_null == 0 {
                    break;
                }
            }

            distance += 1;
        }

        RepetitionState::None
    }

    /// 千日手の詳細な状態を判定(探索用、ply 制約付き、cached/current-state ベース)
    ///
    /// `current_state()` にキャッシュされた `repetition_distance` /
    /// `repetition_type` をそのまま用いる lightweight 判定。
    /// `repetition_distance < ply` の signed 比較で判定するため、
    /// 4回目以降の同一局面で `repetition_distance < 0` の場合は `ply == 0` でも
    /// 即座に千日手を返す。
    ///
    /// `repetition_state_with_ply()` とは意味が異なり、こちらは state stack を
    /// 再走査しない。探索ホットパスで snapshot 判定が必要な
    /// ときはこちらを使う。
    #[must_use]
    pub fn cached_repetition_state_with_ply(&self, ply: usize) -> RepetitionState {
        self.cached_repetition_state_with_found_ply_impl(ply)
            .map_or(RepetitionState::None, |(state, _)| state)
    }

    /// 千日手の詳細な状態を判定
    ///
    /// 同一局面の繰り返しを検出し、連続王手の千日手や優等/劣等局面も判定する。
    #[must_use]
    pub fn repetition_state(&self) -> RepetitionState {
        let stack = self.state_stack();
        let current_state = stack.hot(self.st_index);
        let repetition_type = current_state.repetition_type;

        if matches!(repetition_type, RepetitionState::Superior | RepetitionState::Inferior) {
            return repetition_type;
        }

        if current_state.repetition_counter >= 3 {
            return repetition_type;
        }

        RepetitionState::None
    }

ソースコード

rsshogi の千日手判定実装

rsshogi では apply_move32() の中で repetition_counter を差分更新します(4手前から2手ずつ遡る O(1) 計算)。 is_repetition(threshold) はカウンタを閾値と比較するだけです。

将棋では同一手番の局面のみを比較するため、内部的には4手前から2手ずつ遡ります:

局面A(先手番)
  ↓ 先手の指し手
局面B(後手番)
  ↓ 後手の指し手
局面C(先手番)← 局面Aと比較対象(2手ずつ遡る)

連続王手の千日手判定

連続王手の千日手は repetition_state() の戻り値で判定します。 is_perpetual_check() という API は存在しません。 continuous_check カウンタが apply_move32() のたびに更新され、 連続王手側は RepetitionState::Lose / 受けている側は RepetitionState::Win が返ります。

use rsshogi::types::RepetitionState;

// 千日手の種類を判定
match pos.repetition_state() {
    RepetitionState::None    => { /* 千日手ではない */ }
    RepetitionState::Draw    => { /* 引き分けの千日手 */ }
    RepetitionState::Win     => { /* 相手の連続王手千日手(手番側の勝ち)*/ }
    RepetitionState::Lose    => { /* 自分の連続王手千日手(手番側の負け)*/ }
    RepetitionState::Superior   => { /* 優等局面 */ }
    RepetitionState::Inferior   => { /* 劣等局面 */ }
}

256手ルール(最大手数ルール)

ルール概要

対局が256手に達した場合、257手目の局面は送信されず、引き分けとなります。 ただし、256手目で詰みが発生している場合は、その時点で勝敗が決します。

実装上の落とし穴

256手ルールの実装には、詰み判定との相互作用に関する微妙な問題があります。

よくある実装ミス

多くのエンジンは、以下のような単純な実装をしてしまいます:

// ❌ 間違った実装例(is_max_moves_draw() は rsshogi に存在しない)
if position.game_ply() >= 256 {
    return Score::DRAW;  // 詰みチェックをせずに引き分け判定
}

rsshogi では手数は pos.game_ply() で取得できます。詰み判定は探索エンジン側で実装します。

この実装の問題点は、256手目で実際には詰んでいるにもかかわらず、引き分けと判定してしまう可能性があることです。

正しい判定順序

理想的には、以下の順序で判定すべきです:

  1. 256手に達したかをチェック
  2. 詰みかどうかをチェック
  3. 詰みでなければ引き分け

しかし、詰み判定は計算コストが高いため、多くのエンジンは探索の中で段階的に詰みを検出します。 これにより、256手ルールと詰み判定の順序が曖昧になる可能性があります。

実用的な対処法

やねうら王の開発者が推奨する対処法は、最大手数を少し高めに設定することです:

const MAX_MOVES_TO_DRAW: usize = 258;  // 256より少し高めに設定

if position.game_ply() >= MAX_MOVES_TO_DRAW {
    return Score::DRAW;
}

この方法により、256手ルール付近での詰み判定の曖昧さを回避できます。

大会ごとの最大手数の違い

実際の大会では、最大手数が異なることがあります:

  • WCSC(世界コンピュータ将棋選手権): 320手
  • 電竜戦: 512手
  • floodgate: 256手

エンジンは、USI プロトコルを通じて最大手数を設定できるようにすることが推奨されます:

# USI setoption での設定例
setoption name MaxMovesToDraw value 320

パフォーマンスと正確性のトレードオフ

詰み判定を毎手実行するのは現実的ではありません。 そのため、以下のような段階的アプローチが一般的です:

  1. 探索中の詰み検出: アルファベータ探索で自然に検出
  2. 最大手数付近での特別処理: 250手を超えたあたりから詰みチェックを強化
  3. 猶予付き最大手数: 256ではなく258などに設定

持将棋(Impasse)

ルール概要

両者の玉が敵陣に入り、膠着状態になった場合、点数計算により勝敗を決定します:

  • 大駒(飛・角): 5点
  • 小駒(その他): 1点

両者とも以下の条件を満たす場合、点数で判定:

  • 先手:27点以上
  • 後手:27点以上

両者とも27点以上なら引き分け。 どちらかが27点未満なら、その手番側の負け。

入玉宣言の実装

rsshogi では can_declare_impasse() / calculate_impasse_score() という API は存在しません。 入玉宣言の判定は declaration_win_move() メソッドで行い、EnteringKingRule に応じて ポイント制・トライルールを切り替えます。

use rsshogi::types::{EnteringKingRule, MOVE_NONE};

// 入玉宣言勝ちの手を取得(宣言できなければ MOVE_NONE を返す)
let win_move = pos.declaration_win_move();
if win_move != MOVE_NONE {
    // 宣言勝ちできる
}

実際のソースコードでは、declaration_win_move メソッドで入玉宣言勝ちを判定します。 EnteringKingRule に応じてポイント制・トライルールを切り替えます:

    /// 宣言勝ち判定
    #[must_use]
    pub fn declaration_win_move(&self) -> Move32 {
        match self.entering_king_rule {
            EnteringKingRule::None | EnteringKingRule::Unset => {
                return MOVE_NONE;
            }
            EnteringKingRule::TryRule => {
                let us = self.turn();
                let king_try_sq = if us == Color::BLACK { SQ_51 } else { SQ_59 };
                let king_sq = self.king_square(us);
                if king_sq.is_none() {
                    return MOVE_NONE;
                }

                if !KING_ATTACKS[king_sq].test(king_try_sq) {
                    return MOVE_NONE;
                }
                if self.bitboards().color_pieces(us).test(king_try_sq) {
                    return MOVE_NONE;
                }
                if self.is_attacked_by_color_with_king(us.flip(), king_try_sq, king_sq) {
                    return MOVE_NONE;
                }

                return Move32::normal(
                    king_sq,
                    king_try_sq,
                    Piece::from_parts(us, PieceType::KING),
                );
            }
            EnteringKingRule::Point24
            | EnteringKingRule::Point24Handicap
            | EnteringKingRule::Point27
            | EnteringKingRule::Point27Handicap => {}
        }

        let us = self.turn();

        // 条件5: 玉に王手がかかっていない
        if !self.checkers().is_empty() {
            return MOVE_NONE;
        }

        // 条件1: 宣言側の手番である(自明)

        // 玉の位置を取得
        let king_sq = self.king_square(us);
        if king_sq.is_none() {
            return MOVE_NONE; // 玉がない場合(異常な局面)
        }

        // 条件2: 宣言側の玉が敵陣三段目以内に入っている
        // 先手(BLACK)の敵陣 = 後手の陣地1-3段目 = rank 0-2(内部表現)
        // 後手(WHITE)の敵陣 = 先手の陣地1-3段目 = rank 6-8(内部表現)
        let king_rank = king_sq.rank();
        let in_enemy_camp = match us {
            Color::BLACK => king_rank.raw() <= 2, // 後手の1-3段目(rank 0-2)
            Color::WHITE => king_rank.raw() >= 6, // 先手の1-3段目(rank 6-8)
        };

        if !in_enemy_camp {
            return MOVE_NONE;
        }

        // 条件3と4: 持点計算と駒数カウント
        let hand = self.hand(us);
        let mut points: i32 = 0;

        // 持ち駒の点数計算(点数のみ、駒数は敵陣の駒だけ)
        let hand_pieces = [
            (HandPiece::ROOK, PieceType::ROOK, 5),
            (HandPiece::BISHOP, PieceType::BISHOP, 5),
            (HandPiece::GOLD, PieceType::GOLD, 1),
            (HandPiece::SILVER, PieceType::SILVER, 1),
            (HandPiece::KNIGHT, PieceType::KNIGHT, 1),
            (HandPiece::LANCE, PieceType::LANCE, 1),
            (HandPiece::PAWN, PieceType::PAWN, 1),
        ];

        for (hand_piece, _, value) in hand_pieces {
            let count = i32::try_from(hand.count(hand_piece)).expect("hand count fits in i32");
            points += count * value;
            // 持ち駒は点数に加算するが、駒数には加算しない
        }

        // 盤上の敵陣三段目以内の駒をカウント(玉を除く)
        // 先手(BLACK)の敵陣 = 後手の陣地1-3段目 = rank 0-2(内部表現)
        // 後手(WHITE)の敵陣 = 先手の陣地1-3段目 = rank 6-8(内部表現)
        let enemy_camp_mask = match us {
            Color::BLACK => {
                // 後手の1-3段目(rank 0-2)
                Bitboard::rank_mask(Rank::new(0))
                    | Bitboard::rank_mask(Rank::new(1))
                    | Bitboard::rank_mask(Rank::new(2))
            }
            Color::WHITE => {
                // 先手の1-3段目(rank 6-8)
                Bitboard::rank_mask(Rank::new(6))
                    | Bitboard::rank_mask(Rank::new(7))
                    | Bitboard::rank_mask(Rank::new(8))
            }
        };

        let our_pieces_in_camp = self.bitboards().color_pieces(us) & enemy_camp_mask;
        let p1 = i32::try_from(our_pieces_in_camp.count()).expect("camp piece count fits in i32");
        if p1 < 11 {
            return MOVE_NONE;
        }

        let majors_in_camp = (self.bitboards().pieces_for(PieceType::BISHOP, us)
            | self.bitboards().pieces_for(PieceType::HORSE, us)
            | self.bitboards().pieces_for(PieceType::ROOK, us)
            | self.bitboards().pieces_for(PieceType::DRAGON, us))
            & enemy_camp_mask;
        let p2 = i32::try_from(majors_in_camp.count()).expect("camp major count fits in i32");

        points += p1 + p2 * 4 - 1;

        let required_points = self.entering_king_point[us.to_index()];
        if points >= required_points { MOVE_WIN } else { MOVE_NONE }
    }

ソースコード

実装チェックリスト

引き分け判定を実装する際は、以下の点をチェックしてください:

  • 千日手検出でハッシュ値の衝突を考慮しているか
  • 連続王手の千日手を正しく判定しているか
  • 256手ルールで詰み判定との順序を考慮しているか
  • 最大手数を設定可能にしているか(WCSC, 電竜戦など)
  • declaration_win_move() で入玉宣言勝ちを判定しているか
  • EnteringKingRule の設定(ポイント制 / トライルール / なし)が適切か

デバッグのヒント

千日手の誤検出を防ぐ

ハッシュ値の衝突により、異なる局面を同一と誤判定する可能性があります。 rsshogi の is_repetition(threshold)repetition_counter のカウンタを使う O(1) 判定ですが、 ハッシュ衝突への対処が必要な場合はエンジン側で追加検証を行います:

// ハッシュ衝突を検出するための追加チェック(エンジン側の実装例)
fn is_repetition_strict(pos: &Position) -> bool {
    if !pos.is_repetition(3) {
        return false;
    }

    // SFEN 文字列で完全比較(重い処理のためデバッグ用途のみ)
    let current_sfen = pos.to_sfen(None);
    // ... 過去局面と比較するには state_stack を遡る必要があり、
    // rsshogi の公開 API からは直接アクセスできない。
    // 通常は repetition_counter の精度で十分。
    true
}

256手ルール付近のテスト

256手付近の局面をテストケースとして用意し、詰みと引き分けの判定が正しいことを確認します:

#[test]
fn test_256_move_rule_with_mate() {
    use rsshogi::movegen::{Legal, generate_moves};
    use rsshogi::board::MoveList;

    let mut pos = position_from_sfen("position at 255 moves").unwrap();

    // 256手目で詰みの手を指す
    let mate_move = /* 合法手リストから詰み手を取得 */;
    pos.apply_move32(mate_move);

    // 合法手が0かつ王手中なら詰み
    let mut moves = MoveList::new();
    generate_moves::<Legal>(&pos, &mut moves);
    let is_checkmate = moves.is_empty() && pos.is_in_check();
    assert!(is_checkmate);

    // 手数確認は game_ply() で
    assert_eq!(pos.game_ply(), 256);
}

落とし穴

打ち歩詰め vs 突き歩詰め

打ち歩詰め(持ち駒の歩を打って詰ます)は禁じ手ですが、突き歩詰め(盤上の歩を進めて詰ます)は合法です。 この区別は mv.is_drop() で判定しますが、見落としやすいバグの原因です。

と金は二歩ではない

「と金」(成った歩)は駒種としては PRO_PAWN であり、PAWN ではありません。 筋の二歩チェックは PieceType::PAWN のみを対象にし、PRO_PAWN は含めません。 PRO_PAWN を二歩判定に含めてしまうと、と金がある筋に歩が打てなくなる重大なバグになります。

連続王手千日手の判定方向

連続王手千日手は「王手をかけ続けた側」の反則負けです。 現在王手されている局面から過去を遡り、すべての繰り返し局面で王手がかかっていたかを確認する必要があります。 「王手されている側が千日手」ではなく「王手している側が千日手」である点に注意してください。

まとめ

  • 王手判定: 逆利き方式で候補集合を作り、敵駒配置と照合する
  • 二歩判定: 筋マスクと自歩 Bitboard の交差で判定する
  • 打ち歩詰め: 取り返しチェック、玉の逃げ場、ピン判定の同筋例外が要点
  • 千日手: repetition_counter の差分更新(O(1))、連続王手は RepetitionState::Win/Lose で判定
  • 256 手ルール: 詰みとの順序に注意、大会ごとに最大手数が異なる
  • 入玉宣言: 点数計算(大駒 5 点、小駒 1 点)+ 駒数チェック

次に読む

SFEN パーサ: 局面のシリアライズ・デシリアライズに進みます。

参考資料