第II部 局面管理と合法手生成

第7章 終局判定と特殊ルール

将棋には詰みだけでなく、千日手、連続王手の千日手、入玉宣言勝ち、持将棋など、 複数の終局条件が存在します。本章では、Hayanagi がこれらの特殊ルールをどのように実装しているかを解説します。

7.1 terminal_status() の処理フロー

terminal_status() は、現在の局面が終局状態かどうかを判定する中心的な関数です。 探索中に呼び出され、終局であれば勝ち・負け・引き分けの情報とその理由を返します。

// position.cpp 582行目
TerminalStatus Position::terminal_status() const {
    if (is_perpetual_check_loss_for_opponent()) {
        return TerminalStatus{TerminalOutcome::Win, TerminalReason::PerpetualCheck};
    }
    if (rules_.entering_king_rule == EnteringKingRule::TryRule) {
        if (is_try_rule_win(Color::Black)) {
            return TerminalStatus{
                side_to_move_ == Color::Black ? TerminalOutcome::Win : TerminalOutcome::Loss,
                TerminalReason::TryRule,
            };
        }
        if (is_try_rule_win(Color::White)) {
            return TerminalStatus{
                side_to_move_ == Color::White ? TerminalOutcome::Win : TerminalOutcome::Loss,
                TerminalReason::TryRule,
            };
        }
    }
    if (is_repetition_draw()) {
        return TerminalStatus{TerminalOutcome::Draw, TerminalReason::Repetition};
    }
    if (rules_.max_moves_to_draw > 0 && ply_count_ >= rules_.max_moves_to_draw &&
        !is_in_check(side_to_move_)) {
        return TerminalStatus{TerminalOutcome::Draw, TerminalReason::MoveLimit};
    }
    if (rules_.entering_king_rule != EnteringKingRule::NoEnteringKing &&
        rules_.entering_king_rule != EnteringKingRule::TryRule) {
        if (can_declare_win()) {
            return TerminalStatus{TerminalOutcome::Win, TerminalReason::DeclarationWin};
        }
        if (is_impasse_position()) {
            const int own_points =
                impasse_points(side_to_move_) + handicap_entering_king_bonus(side_to_move_);
            const int opponent_points = impasse_points(opposite(side_to_move_)) +
                                        handicap_entering_king_bonus(opposite(side_to_move_));
            const int own_threshold = impasse_threshold(side_to_move_);
            const int opponent_threshold = impasse_threshold(opposite(side_to_move_));
            if (own_points >= own_threshold && opponent_points >= opponent_threshold) {
                return TerminalStatus{TerminalOutcome::Draw, TerminalReason::Impasse};
            }
            if (own_points < own_threshold && opponent_points >= opponent_threshold) {
                return TerminalStatus{TerminalOutcome::Loss, TerminalReason::Impasse};
            }
            if (own_points >= own_threshold && opponent_points < opponent_threshold) {
                return TerminalStatus{TerminalOutcome::Win, TerminalReason::Impasse};
            }
        }
    }
    return TerminalStatus{};
}

判定の優先順位は以下の通りです。

  1. 連続王手の千日手 (is_perpetual_check_loss_for_opponent) — 相手の反則負け
  2. トライルール — 玉が相手の玉の初期位置に到達(TryRule設定時のみ)
  3. 千日手 (is_repetition_draw) — 同一局面4回で引き分け
  4. 手数上限 — 王手中でなければ引き分け
  5. 入玉宣言勝ち (can_declare_win) — CSAルールに基づく宣言
  6. 持将棋 (is_impasse_position) — 双方入玉時の点数判定

注目すべき点として、合法手が0(詰み/ステイルメイト)の判定はここでは行われていません。 合法手数のチェックは探索側で generate_legal_moves() の結果が空かどうかで判定します。 terminal_status() は合法手生成を呼ばずに判定できる終局条件のみを扱います。

返り値の TerminalStatus 構造体は TerminalOutcome(None/Win/Draw/Loss)と TerminalReason(None/DeclarationWin/Repetition/PerpetualCheck/Impasse/MoveLimit/TryRule)を持ち、 is_terminal() で終局かどうかを判定できます。

7.2 千日手判定 is_repetition_draw()

千日手とは、同一局面が4回出現した場合に引き分けとなるルールです。 Hayanagi では HistoryNode 連結リストを使用してこの判定を行います。

HistoryNode 連結リスト

// position.h 63行目
struct HistoryNode {
    std::uint64_t key = 0;
    Color side_to_move = Color::Black;
    bool side_in_check = false;
    std::shared_ptr<const HistoryNode> previous;
};

各ノードは局面のZobristハッシュ値 (key)、手番 (side_to_move)、王手中か (side_in_check)、 前の局面へのポインタ (previous) を持ちます。 手を指すたびに append_history() で新しいノードが先頭に追加されます。

// position.cpp 1474行目
void Position::append_history() {
    history_ = std::make_shared<HistoryNode>(
        HistoryNode{repetition_key(), side_to_move_, is_in_check(side_to_move_), history_});
}

判定ロジック

// position.cpp 1483行目
bool Position::is_repetition_draw() const {
    if (history_ == nullptr) {
        return false;
    }

    int occurrences = 0;
    for (auto node = history_; node != nullptr; node = node->previous) {
        if (node->key == history_->key && ++occurrences >= 4) {
            return true;
        }
    }
    return false;
}

現在の局面のハッシュキー (history_->key) と一致するノードを履歴全体から数え、 4回以上出現していれば千日手と判定します。 repetition_key() は position_key_ をそのまま返しますが、 この値には手番の情報も含まれている(Zobristの side_to_move が XOR されている)ため、 手番が異なる同一配置は別局面として扱われます。

7.3 連続王手の千日手 is_perpetual_check_loss_for_opponent()

千日手のうち、一方が連続して王手をかけ続けている場合は、 王手をかけている側の反則負けとなります。Hayanagi ではこれを 「相手(直前に指した側)にとって不利な連続王手」として判定します。

// position.cpp 1505行目
bool Position::is_perpetual_check_loss_for_opponent() const {
    if (history_ == nullptr || !history_->side_in_check) {
        return false;
    }

    int occurrences = 0;
    std::shared_ptr<const HistoryNode> earliest;
    for (auto node = history_; node != nullptr; node = node->previous) {
        if (node->key == history_->key) {
            ++occurrences;
            earliest = node;
            if (occurrences >= 4) {
                break;
            }
        }
    }
    if (occurrences < 4 || earliest == nullptr) {
        return false;
    }

    for (auto node = history_; node != nullptr; node = node->previous) {
        if (node->side_to_move == side_to_move_ && !node->side_in_check) {
            return false;
        }
        if (node == earliest) {
            break;
        }
    }
    return true;
}

判定アルゴリズム

判定は3段階で行われます。

  1. 前提条件: 現在の局面で手番側が王手されている (history_->side_in_check)。 これが false なら、相手の連続王手ではないので即座に false を返す
  2. 千日手の確認: 同一局面が4回出現しているかを調べ、最も古い出現ノード (earliest) を記録する
  3. 連続王手の確認: earliest から現在の局面までの全履歴を走査し、 手番側(王手を受けている側)のすべての局面で side_in_check が true であることを確認する。 もし1度でも王手がかかっていない局面があれば、連続王手ではないので false

この関数が true を返す場合、terminal_status() は TerminalOutcome::Win を返します。 つまり、現在の手番側(王手を受けている側)の勝ちです。

7.4 入玉宣言勝ち can_declare_win()

CSA(コンピュータ将棋協会)のルールでは、一定条件を満たした場合に 「宣言」によって勝ちとなることができます。

// position.cpp 633行目
bool Position::can_declare_win() const {
    if (rules_.entering_king_rule == EnteringKingRule::NoEnteringKing ||
        rules_.entering_king_rule == EnteringKingRule::TryRule) {
        return false;
    }
    const Color color = side_to_move_;
    const int king_square = find_king(color);
    if (king_square == -1 || !is_in_promotion_zone(color, square_row(king_square))) {
        return false;
    }
    if (is_in_check(color)) {
        return false;
    }
    if (pieces_in_opponent_camp(color) < 10) {
        return false;
    }
    if (declaration_points(color) + handicap_entering_king_bonus(color) <
        declaration_threshold(color)) {
        return false;
    }
    return !opponent_has_mate_in_one(color);
}

宣言勝ちの条件

以下の全てを満たす必要があります。

  1. 入玉ルールが有効である(NoEnteringKing でも TryRule でもない)
  2. 自玉が敵陣(成りゾーン)に入っている
  3. 自玉が王手されていない
  4. 敵陣に自分の駒(玉を除く)が10枚以上ある
  5. 宣言ポイントが閾値以上である
  6. 相手に1手詰めがない(opponent_has_mate_in_one)

点数計算

declaration_points() は敵陣にある自分の駒と持ち駒の点数を合計します。

// position.cpp 1633行目
int Position::declaration_points(Color color) const {
    int points = 0;
    for (int square = 0; square < kSquareCount; ++square) {
        const int piece = board_[square];
        if (is_empty(piece) || piece_color(piece) != color ||
            piece_type(piece) == PieceType::King) {
            continue;
        }
        if (is_in_promotion_zone(color, square_row(square))) {
            points += impasse_point_value(piece_type(piece));
        }
    }
    for (int index = 1; index < kHandPieceKinds; ++index) {
        const PieceType piece = static_cast<PieceType>(index);
        points += hands_[static_cast<int>(color)][index] * impasse_point_value(piece);
    }
    return points;
}

点数は impasse_point_value() で定義され、大駒(飛車・角)が5点、小駒が1点です。 宣言ポイントの計算対象は、敵陣にある駒(盤上)と持ち駒(手駒)です。 自陣や中段にある駒は数えません。

7.5 持将棋判定 is_impasse_position()

持将棋とは、双方の玉が互いの敵陣に入り込み、 どちらも詰ませることが困難な状態を指します。

// position.cpp 1601行目
bool Position::is_impasse_position() const {
    const int black_king = find_king(Color::Black);
    const int white_king = find_king(Color::White);
    if (black_king == -1 || white_king == -1) {
        return false;
    }
    if (!is_in_promotion_zone(Color::Black, square_row(black_king)) ||
        !is_in_promotion_zone(Color::White, square_row(white_king))) {
        return false;
    }
    if (is_in_check(Color::Black) || is_in_check(Color::White)) {
        return false;
    }
    return true;
}

持将棋の前提条件は以下の3つです。

この条件を満たす場合、terminal_status() 内で点数計算が行われ、 双方の点数を閾値と比較して勝敗を決定します。

7.6 入玉ルールバリアント(EnteringKingRule)

Hayanagi は6種類の入玉ルールをサポートしています。

// position.h 14行目
enum class EnteringKingRule : int {
    NoEnteringKing = 0,
    CSARule24,
    CSARule24H,
    CSARule27,
    CSARule27H,
    TryRule,
};
ルール説明宣言閾値持将棋閾値
NoEnteringKing入玉ルールなし--
CSARule24CSA 24点法先後とも31点先後とも24点
CSARule24HCSA 24点法(駒落ち補正あり)先後とも31点先後とも24点
CSARule27CSA 27点法先手28点/後手27点先手28点/後手27点
CSARule27HCSA 27点法(駒落ち補正あり)先手28点/後手27点先手28点/後手27点
TryRuleトライルール--

CSA Rule 24 と Rule 27 の違い

閾値の設定は declaration_threshold() と impasse_threshold() で定義されています。

// position.cpp 1571行目
int Position::declaration_threshold(Color color) const {
    switch (rules_.entering_king_rule) {
        case EnteringKingRule::CSARule24:
        case EnteringKingRule::CSARule24H:
            return 31;
        case EnteringKingRule::CSARule27:
        case EnteringKingRule::CSARule27H:
            return color == Color::Black ? 28 : 27;
        case EnteringKingRule::NoEnteringKing:
        case EnteringKingRule::TryRule:
        default:
            return kInfinity;
    }
}

int Position::impasse_threshold(Color color) const {
    switch (rules_.entering_king_rule) {
        case EnteringKingRule::CSARule24:
        case EnteringKingRule::CSARule24H:
            return 24;
        case EnteringKingRule::CSARule27:
        case EnteringKingRule::CSARule27H:
            return color == Color::Black ? 28 : 27;
        case EnteringKingRule::NoEnteringKing:
        case EnteringKingRule::TryRule:
        default:
            return kInfinity;
    }
}

駒落ち補正(CSARule24H / CSARule27H)

駒落ち対局では盤上の駒の総数が通常より少なくなります。 handicap_entering_king_bonus() は、欠けている駒の点数を後手側にボーナスとして加算します。

handicap_entering_king_bonus() は、盤上と持ち駒の全駒数を数え、 通常の駒数(kFullPieceCounts: 歩9枚、香2枚、桂2枚、銀2枚、金2枚、角1枚、飛1枚)との差分に ポイント値を掛けてボーナスを計算します。 このボーナスは後手(上手)にのみ適用され、駒落ち対局での公平性を保ちます。

TryRule

トライルールは、自玉が相手の玉の初期位置に到達することで勝ちとなるルールです。 先手の場合は5一(kBlackTrySquare = 4)、 後手の場合は5九(kWhiteTrySquare = 76)が目標マスです。

// position.cpp 1497行目
bool Position::is_try_rule_win(Color color) const {
    const int king_square = find_king(color);
    if (king_square == -1) {
        return false;
    }
    return king_square == (color == Color::Black ? kBlackTrySquare : kWhiteTrySquare);
}

トライルールが有効な場合、CSAルールの宣言勝ちや持将棋判定は行われません。

7.7 手数上限(max_moves_to_draw)

PositionRules の max_moves_to_draw フィールドで手数上限を設定できます。

// position.h 23行目
struct PositionRules {
    int max_moves_to_draw = 500;
    EnteringKingRule entering_king_rule = EnteringKingRule::CSARule24;
    bool generate_all_legal_moves = true;
};

デフォルトは500手です。terminal_status() 内で以下のように判定されます。

    if (rules_.max_moves_to_draw > 0 && ply_count_ >= rules_.max_moves_to_draw &&
        !is_in_check(side_to_move_)) {
        return TerminalStatus{TerminalOutcome::Draw, TerminalReason::MoveLimit};
    }

重要な点として、王手がかかっている場合は手数上限を適用しないという条件があります。 これは、王手中に引き分けとしてしまうと不公平になるためです。 また、max_moves_to_draw が0以下の場合は手数上限なしとして扱われます。

7.8 terminal_status() と探索の連携

terminal_status() は合法手の有無を判定しません。 詰み(合法手がゼロで王手中)やステイルメイト(合法手がゼロで王手なし)の判定は、 探索側で generate_legal_moves() の結果を使って行います。 この設計により、terminal_status() は合法手生成を呼ばずに高速に判定でき、 合法手生成はそれが本当に必要な場合にのみ実行されます。