8×8じゃない盤面でリバーシAIを成立させる

リバーシAIは「四隅は100点」という座標ごとの点数表を持つのが定番です。盤面が毎回変わるゲームでそれが使えず、定石そのものを実装し直した話。

無限リバーシは、毎回ランダムな形の盤面でリバーシを遊ぶゲームです。20マスから始まって、勝つたびに6マスずつ大きくなり、最大120マスまで広がります。

盤面の形が毎回違うと、COMの思考をどう作るかが問題になります。リバーシAIの定番手法が、そのままでは使えないからです。

定番の手法が使えない

リバーシのAIは、ふつうマスごとの点数表を持っています。

100  -20   10    5    5   10  -20  100
-20  -50   -2   -2   -2   -2  -50  -20
 10   -2    5    1    1    5   -2   10
 ...

四隅は100点、その隣は−20点、斜め隣は−50点。この表と盤面を照らし合わせて、自分の駒が置かれているマスの点数を合計すれば、局面の良し悪しが分かります。単純ですが、これだけでかなり強くなります。

問題は、この表が8×8であることを前提にしている点です。マス数も形も毎回違うこのゲームでは、表を用意しようがありません。

なぜ四隅は強いのか、を実装する

そこで一段戻って考えました。そもそも、なぜ四隅は100点なのか。

答えは単純です。四隅の駒は絶対にひっくり返されないからです。リバーシで駒が裏返るのは、両側から挟まれたときだけ。角は片側に盤がないので、挟みようがありません。

では、「挟まれにくさ」を数値化するにはどうすればいいか。ここで気づきました。

そのマスが盤の内側にいくつ隣接マスを持っているかを数えればいい。

  • 四隅 → 上下左右のうち、盤内にあるのは2つだけ
  • 辺の途中 → 3つ
  • 完全な内側 → 4つ

隣接が少ないほど、挟まれる方向が少ない。つまり安全です。

実装

こうなりました。

export function getCellWeight(cell: CellKey, board: Board): number {
  const neighbors = getNeighborCount(cell, board);
  if (neighbors <= 2) return 10; // 角的
  if (neighbors === 3) return 3;  // 辺的
  return 1;                        // 内部
}

たった4行です。座標はどこにも出てきません。盤の形さえ分かれば、どのマスが「角」なのかが自動的に決まります

そして重要なのは、この計算が普通の8×8盤にも正しく当てはまることです。四隅を入力すれば10が返り、辺なら3、内側なら1。定石の点数表を、形から再導出できたわけです。

不定形の盤面では、「角」は四隅とは限りません。盤から突き出た半島の先端や、くびれの端が角になります。人間が見ても直感的に「ここは強そう」と感じる場所と、だいたい一致します。

角の隣も同じ考え方で

リバーシでもうひとつ有名なのが、角の隣に置いてはいけないという定石です。そこに置くと、相手に角を取られてしまうからです。

これも座標を使わずに書けます。「価値10のマスに隣接していて、自分は価値10でないマス」を探して、大きなマイナスを与えるだけです。

for (const cell of board.cells) {
  if (weights.get(cell) === 10) {
    for (const [dr, dc] of DIRECTIONS_4) {
      const adj = ...;
      const adjWeight = weights.get(adj);
      if (adjWeight !== undefined && adjWeight !== 10) {
        weights.set(adj, Math.min(adjWeight, -5));
      }
    }
  }
}

Math.min を使っているのは、すでにマイナスがついているマスを、うっかり上書きしないためです。複数の角に隣接しているマスは、より危険な値のほうを残します。

定石を写すのではなく、定石の理由を実装する

この作業を通して学んだのは、これでした。

点数表を写経していたら、8×8以外では何もできませんでした。「なぜその点数なのか」まで遡ったから、任意の形に一般化できたわけです。

しかも副次的な効果がありました。コードが短くなり、検証しやすくなったのです。64個の数字が正しいかを目視で確かめるのは大変ですが、「隣接数を数えて分類する」なら、小さなテストで確認できます。

もうひとつの評価軸:置ける場所の数

位置の価値だけでは足りませんでした。リバーシには着手可能数という重要な概念があります。自分が置ける場所が多く、相手が置ける場所が少ないほど有利、という考え方です。

序盤に駒を取りすぎると、自分の駒が外側に露出して相手の選択肢が増え、逆に自分の選択肢は減ります。リバーシで「序盤は少なく取れ」と言われるのはこのためです。

そこで評価に、置ける場所の数の差 × 3を加えました。

const myMoves = getValidMoves(state, player).length;
const oppMoves = getValidMoves(state, opponent(player)).length;
score += (myMoves - oppMoves) * 3;

係数の3は調整の結果です。大きくしすぎると位置の価値を無視して身動きだけを優先するようになり、角を取らなくなります。小さすぎると序盤に取りすぎて自滅します。

読みの深さは1手に留めた

このCOMは、1手先しか読みません。自分が1手置いた直後の盤面を評価して、いちばん良いものを選ぶだけです。

深く読ませることは技術的には可能でしたが、そうしませんでした。理由は3つあります。

速度。盤面が120マスになると、置ける場所も増えます。数手先まで全部展開すると、スマートフォンでは数秒固まります。待たされるゲームは暇つぶしになりません。

難易度。リバーシの読みは深くするほど強くなります。3手も読めば、普通の人はまず勝てません。このゲームは勝ち進んで盤面を大きくするのが目的なので、適度に勝てないと成立しません。

そして、1手先でも十分強かった。実際に遊ぶと、角をきちんと押さえてきますし、着手可能数も見ているので中盤も手強い。探索の深さより、評価関数の質のほうが体感の強さに効くというのが結論でした。

これは意外な発見でした。強いAIを作りたいなら深く読ませるべきですが、「ちょうどよく強いAI」を作るなら、浅く読んで評価を丁寧にするほうが制御しやすいのです。

わざと少しだけブレさせる

最後にひとつ。COMの評価には、±10%程度のランダムな揺らぎを入れています。

const jittered = score * (0.9 + Math.random() * 0.2);

完全に決定的なAIだと、必勝手順を1つ見つけたら永遠に勝ててしまいます。同じ手順をなぞるだけの作業になってしまう。

揺らぎがあると、同じ盤面でも違う手を打つことがあります。プレイヤーは毎回考え直す必要があるし、「今日は運が良かった」という感覚も生まれます。

ただしこの揺らぎは評価値に対する比率なので、明らかに良い手と明らかに悪い手が入れ替わることはありません。僅差の選択肢の中でだけ迷うという、人間らしい挙動になります。

実際の強さは無限リバーシで確かめてみてください。1手しか読んでいないとは思えないくらい、角はきっちり取ってきます。

広告