TL;DR — 将棋系 8×8 ゲームのルールエンジンを Rust で実装して学んだ3点:持ち駒は局面の同一性を変える/打ちは分岐係数を支配する/打ち歩詰めは「合成ルール」なのでレイヤー設計が要る。コードはすべて実行済みの出力つきです。
将棋・チェス・シャンチーは、同じ祖先から育った兄弟のようなゲームです。Ōgi(オーギ、「王のゲーム」)は将棋に着想を得た 8×8 の二人零和ゲームで、持ち駒(打ち)を残しつつ、王の隣に「姫」を置くなどの再構成をしています。正式ルールは ja.ogi.page にあります。以下、駒は英語名で書きます — fu は将棋の歩に相当する駒です。
この記事は、そのルールエンジン(sashite-sanki-engine、Rust、Apache-2.0)を実装して面白かった3点のメモです。手元で試すには:
cargo new ogi-notes && cd ogi-notes
cargo add sashite-sanki-engine@0.9
各スニペットは use sashite_sanki_engine::prelude::*; を先頭に置けば main にそのまま貼れます。
1. 持ち駒があると「局面」は盤面だけでは決まらない
チェスの FEN に慣れていると、局面 = 盤面 + 手番 + 少々、と思いがちです。持ち駒のあるゲームでは、手の中の駒まで含めて初めて局面が定まります。使っている表記 FEEN は3フィールド構成で、盤面/持ち駒/スタイルを持ち、手番はスタイル欄の大文字・小文字の順で表現されます。
use sashite_sanki_engine::prelude::*;
let feen = "k^7/8/8/3f4/8/8/8/3R3K^ / J/j";
let pos = Position::parse(feen).unwrap();
let mv = Move::parse(r#"["d1","d5",null]"#).unwrap();
let after = engine::apply(&pos, &mv).unwrap();
println!("{}", after.to_feen());
// => k^7/8/8/3R4/8/8/8/7K^ F/ j/J
ルークが d5 の fu を取ると、持ち駒欄に F/(先手の手に fu が1枚)が現れ、スタイル欄が J/j から j/J へ反転して手番の移動を示します。千日手(三回同一局面)の判定キーもこの文字列そのものです — 盤面が同じでも手の中身が違えば別局面、が自動的に成立します。
2. 打ちは分岐係数を爆発させる
打てる駒が1枚あるだけで、合法手の数は桁が変わります。この局面で数えてみます(先手の持ち駒に F が1枚):
8 | k . . . . . . .
7 | . . . . . . . .
6 | . . N . . . . .
5 | . . . . . . . .
4 | . . . . . . . .
3 | . . . . . . . .
2 | . . . . . . . .
1 | . R . . . . . K
a b c d e f g h 先手の持ち駒: F(fu)
let feen = "k^7/8/2N5/8/8/8/8/1R5K^ F/ J/j";
let pos = Position::parse(feen).unwrap();
let moves = engine::legal_moves(&pos);
let drops = moves.iter()
.filter(|m| matches!(m, Move::Drop { .. }))
.count();
println!("legal: {} (drops: {})", moves.len(), drops);
// => legal: 76 (drops: 52)
| 局面 | 合法手 | うち打ち |
|---|---|---|
| 初期局面 | 22 | 0 |
| 上図(手に fu ×1) | 76 | 52 |
fu 1枚で合法手の 3 分の 2 が打ちになります。探索・評価を書く側にとって、これは「将棋系ゲームのエンジンはチェスエンジンの定数調整では済まない」ことのいちばん簡潔な証拠だと思います。指し手の表現は [出発地, 目的地, actor] の JSON で、打ちは出発地を null にして駒名を actor に書きます。
3. 打ち歩詰めは「合成されたルール」
Ōgi は将棋から打ち歩詰めの禁止も継承しています。実装して初めて分かるのは、このルールの層の深さです。「fu を打って詰みになる手は指せない」を判定するには、打った後の局面で相手の合法手を全探索する必要があります。つまり合法性レイヤーの上に、終局判定レイヤーを重ねた合成ルールであり、単発の駒移動チェックと同じ層には書けません。
先ほどの図をもう一度。a7 に fu を打つと a8 の玉に王手(fu は前に利く)。逃げ場は b8(ナイト c6 が利く)と b7(ルーク b1 が利く)のみ、a7 の fu 自体もナイト c6 が守っている — 詰みです。だからこの打ちは違法:
let drop = Move::parse(r#"[null,"a7","fu"]"#).unwrap();
match engine::validate(&pos, &drop) {
Ok(()) => println!("legal"),
Err(e) => println!("rejected: {e:?}"),
}
// => rejected: Uchifuzume
let elsewhere = Move::parse(r#"[null,"e5","fu"]"#).unwrap();
println!("{}", engine::validate(&pos, &elsewhere).is_ok());
// => true(詰みでない打ちは合法)
同じ詰みでも盤上の駒で届ける分には合法です。禁じられているのは「fu の打ち」による詰みだけ — ルールの非対称性がそのまま実装のレイヤー構造に写る、気持ちのよい例でした。
まとめ
- 持ち駒のあるゲームでは、局面の同一性に手の中身を含める(表記が最初からそれを強制してくれると楽)。
- 打ちは分岐係数を支配する。データで見ると一目瞭然(76手中52手)。
- 打ち歩詰めのような合成ルールは、合法性チェックを層で設計していないと後付けできない。
ルール: ja.ogi.page / ogi.page(英語)。表記の仕様: sashite.dev。エンジンと探索: sashite-sanki-engine・sashite-sanki-player(どちらも crates.io、Apache-2.0)。ブラウザでそのまま遊べます: play.ogi.page(アカウント不要)。
ルールの解釈やエンジンの判定がおかしい局面を見つけたら、それがいちばんありがたいフィードバックです。