▍AI Dev Lab
TurnStone

TurnStone ができるまで — リバーシ AI の読みを盤に出す

リバーシ AI 対戦の開発ログ。評価関数を 1 つの数値に潰さず位置・打てる手・角・確定石・石数の内訳で返す形にし、置けるマス全部に評価値を重ねた記録です。全手の点数を正しく出すために、根での枝刈りを捨てた話も書いています。

TurnStone は、一人で遊べるリバーシ (8×8) です。ただし主役は AI の強さではありません。AI が今どう考えているかを、盤の上に全部出すことに振ったサービスです。

チェス AI (GambitBoard) を作ったときに残った不満がありました。強くはなったが、なぜその手なのかは誰にも見えない。評価関数も探索も自分で書いたのに、外から見えるのは「AI が指した 1 手」だけです。そこで今回は、評価を隠さない側に振りました。

#🎯 「AI と戦う」ではなく「AI の読みを見せる」

リバーシは、初心者と経験者で打ち方が正反対になる珍しいゲームです。初めての人は「たくさん返せる手」を打ちます。経験者は逆に、序盤はできるだけ返さない。

この差を言葉で説明しても伝わりません。「石数より打てる手の数が大事」と書いたところで、実際に打つときには目の前の枚数に引っ張られます。

なので、打つ前に全部の候補マスへ評価値を出すことにしました。

  • 置けるマスすべてに、AI が計算した点数を数字で重ねる
  • 点数が高いほど色を濃くする
  • 最善手だけ青で囲む

「8 枚返せるマス」が -12 で、「1 枚しか返らないマス」が +41 になる。数字が目の前にあると、直感に反する定石が体験として入ります。

#🧩 評価関数を 1 つの数値に潰さない

普通、評価関数は number を返します。探索はそれしか要らないからです。

でもこのサービスでは、画面に内訳を出したい。そこで戻り値を構造体にしました。

ts
export interface EvalBreakdown {
  position: number  // マスの重みの合計差 (角と危険地帯)

  mobility: number  // 着手可能数の差

  corners: number   // 角をいくつ確保しているかの差

  stable: number    // 二度と返らない石の差

  discs: number     // 石数の差

  total: number     // 上記の重み付き合計

}

探索は total だけを見ます。UI は 5 項目を全部表示します。同じ計算から、2 つの用途に別々の粒度で渡す形です。

これをやると、序盤に石を取りすぎたときに何が起きるかが数字で追えます。discs が増えるのに mobility がマイナスに振れる。「取ったのに不利になった」が、画面の 2 行の動きとして見えるわけです。

#📐 マスの重み ── 角が +120、その斜め隣が -40

リバーシの定石を、そのまま 64 マスの表にしました。

ts
export const SQUARE_WEIGHTS: readonly number[] = [
  120, -20,  20,   5,   5,  20, -20, 120,
  -20, -40,  -5,  -5,  -5,  -5, -40, -20,
   20,  -5,  15,   3,   3,  15,  -5,  20,
  // ...

]

角が最大の +120。理由は単純で、角は四方から挟まれないので一度取ると二度と返らないからです。

強くマイナスなのは 2 種類あります。角の斜め隣 (-40) と、角の縦横隣 (-20)。どちらも相手に角を渡すきっかけになります。特に斜め隣は、そこに打つと角が直接空くので最悪です。

チェスの Piece-Square Table と考え方は同じですが、リバーシのほうが表の効きが露骨でした。駒の種類が 1 つしかないぶん、位置だけがすべてを決めるからです。

#🔀 打てる手の数は、差ではなく比で測る

最初は着手可能数をそのまま引き算していました。これは間違いでした。

序盤に「自分 10 手 / 相手 8 手」で差が 2 なのと、終盤に「自分 3 手 / 相手 1 手」で差が 2 なのは、意味の重さがまったく違います。後者は相手をほぼ詰ませている状態です。

そこで比率に直しました。

ts
const mobility =
  myMoves + foeMoves === 0 ? 0 : (100 * (myMoves - foeMoves)) / (myMoves + foeMoves)

これで -100 〜 +100 に正規化され、終盤で相手の選択肢を奪ったことが正しく高評価になります。分母が 0 のとき (両者とも打てない = 終局) を先に外しておかないとゼロ除算になるので、そこだけ明示的に潰しています。

#🪨 確定石は「少なく数える」

角を取ると、そこから辺に沿って続く石も返らなくなります。これを確定石と呼びます。

厳密に判定しようとすると、盤の内側にある石まで含めて「もうどの方向からも挟めない」ことを確かめる必要があり、かなり重い処理になります。1 手ごとに何千局面も評価する探索の中では回りません。

なので、角を起点にした辺方向だけを数える控えめな定義にしました。

ts
for (const corner of CORNERS) {
  if (board[corner] !== player) continue
  walk(corner, 0, c === 0 ? 1 : -1) // 横の辺に沿って

  walk(corner, r === 0 ? 1 : -1, 0) // 縦の辺に沿って

}

この定義には数え漏らしがあります。実際には確定している内側の石を見逃します。でも逆はありません。確定していない石を確定と数えることはない。

評価関数で嘘をつくなら、過小評価の方向に倒すべきです。「返らないはずの石が返る」と AI が判断を誤ると、角を取ったあとの読みが全部崩れます。逆に見逃すぶんには、少し弱くなるだけで済みます。

#🔚 終盤で評価をまるごと切り替える

リバーシで一番効いた工夫がこれです。

序盤・中盤の評価は、位置・手数・角・確定石が中心で、石数はほとんど見ません (係数 0.5)。ところが残り 12 マスを切ると、係数を入れ替えます。

ts
const total = endgame
  ? discs * 100 + corners * 60 + stable * 20 + position * 0.2
  : position * 1 + mobility * 8 + corners * 80 + stable * 12 + discs * 0.5

終盤では discs が 100 倍。勝敗はあくまで最後の石数で決まるので、残りが読み切れる範囲に入った瞬間から、石数だけを追いにいくのが正解です。

この切り替えを入れる前は、最後の数手で妙に緩い手を打っていました。位置の点数を気にして、目の前の勝ち石を取りに行かないのです。

#⚠ 全手のスコアが欲しいと、根では枝刈りできない

実装していて一番意外だったのがここです。

アルファベータ探索は「もうこの手は選ばれない」と分かった時点で打ち切ります。速いのですが、打ち切った手のスコアは正確な値ではなくなります。上限か下限が分かっただけで、本当の点数は計算していません。

普通は問題ありません。最善手さえ分かればいいからです。でもこのサービスは全部のマスに数字を出すのが売りです。嘘の数字を並べるわけにはいきません。

なので、根 (最初の 1 手を選ぶ層) だけは窓を共有せず、1 手ずつ独立に読みます。

ts
for (const [at, flips] of moves) {
  const next = applyMove(board, at, player, flips)
  const score = -negamax(next, opponent(player), depth - 1, -Infinity, Infinity, false)
  out.push({ at, score, flips })
}

-Infinity, Infinity を毎回渡しているのは書き忘れではなく、表示のために枝刈りを捨てているということです。1 つ下の層から先は普通にアルファベータが効くので、遅くなるのは根の分だけで済みます。

「見せる」を仕様に入れると、最適化を 1 つ諦めることになる。この交換は事前に予想していませんでした。

#😓 苦労したところ

盤に出す評価は浅く読む。 人間の手番が来るたびに全合法手を 6 手先まで読むと、1 手ごとに画面が固まります。表示用は深さ 3 で頭打ちにしました (Math.min(depth, 3))。AI 自身の着手はレベル通りの深さで読むので、表示の数字と AI の判断が完全一致するとは限りません。速さを取っています。

探索が同期処理なので、描画を 1 回挟む。 そのまま呼ぶと「AI が読んでいます…」が表示される前に計算が始まり、固まったように見えます。setTimeout を挟んでブラウザに描画の機会を渡してから走らせています。Web Worker への移動は積み残しです。

マスが正方形にならなかった。 375px 幅で 1 マスが 36×29 になり、石が楕円になってはみ出していました。CSS Grid は grid-template-columns だけ書くと行の高さは中身なりになります。列と行の両方を 1fr にして、さらに aspect-ratio: 1 を当てて直しました。

css
.board {
  grid-template-columns: repeat(8, 1fr);
  grid-template-rows: repeat(8, 1fr);
  aspect-ratio: 1;
}

呼び名が使えない。 同じルールのゲームで広く知られている名称は株式会社メガハウスの登録商標です。各社が法的リスクを避けて「リバーシ」を名乗っているので、こちらもサービス名・対局画面・メタ情報のどこにも使わず、一般名のリバーシで通しました。検索需要としては惜しいのですが、ここは譲れない部分です。

実装より先に決めたのはこの 1 点だけでした。名前は後から変えると URL も OGP も検索評価も全部やり直しになるので、書き始める前に確定させています。

#🧪 検算 ── ルールから機械的に確かめられることだけ

AI の強さはテストできません。「良い手を選ぶ」に正解が無いからです。なので、ルールから必ず言える性質だけを 18 項目並べました (scripts/check-turn-stone.ts)。

  • 初期配置の合法手はちょうど 4 通り (d3 / c4 / f5 / e6)
  • 初期配置は点対称なので、4 手の評価値がすべて等しい
  • 1 手ごとに、石の総数は「置いた 1 枚 + 返した枚数」だけ増える
  • 1 枚も返せないマスは合法手にならない
  • 角から連続する石だけが確定石に数えられる
  • 探索は、角が取れる局面で角を最上位に選ぶ

2 番目が効きました。対称な盤で 4 手の点数がずれたら、評価関数のどこかに左右非対称なバグがあるということです。表の書き写しミスをこれで 1 回捕まえています。

#🔭 今後の拡張

  • Web Worker へ探索を逃がして、UI を止めずに深く読む
  • 置換表 (同じ局面の再計算を省く)
  • 定石データベース (序盤の数手)
  • 棋譜の書き出しと読み込み

#💡 このサービスから言えること

評価関数を構造体で返すようにしただけで、サービスの性格が変わりました。 中身は教科書どおりのアルファベータ探索で、特別なことは何もしていません。違うのは、計算した内訳を捨てずに画面まで運んでいるところだけです。

そして**「見せる」を仕様に入れると、性能のための最適化と衝突します**。根での枝刈りを捨てたのがまさにそれでした。速さと説明可能性は、どこかで必ずトレードオフになる。AI の中身を見せるサービスを作るなら、最初にどちらを取るか決めておいたほうがいいと感じました。

[ ./next_action ]

読んだら、 TurnStone を実際に動かす。

この開発ログは TurnStone をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ShadowTwin

立体パズルの別解を作る:同じ影と最少の積み木

ShadowTwinの開発記録。3方向の図から別々の最少配置を2つ探す仕組み、5問の独立全探索、スマホの横並び比較、保存・再編集と入力検査を解説します。実照明の計算や能力診断ではありません。

read log →
SlitCard

スリットアニメーションを作る:4コマの合成と透明原稿の設計

SlitCardの開発記録。4コマの帯を交互に合成し、すき間と黒帯で別のコマを見せる仕組みを解説。同縮尺の台紙・透明シートSVG、50mm確認線、手動プレビュー・初期非表示、再編集と実出力検査を整理します。

read log →
WindowLight

日当たりシミュレーションを作る:窓の光を床へ投影する幾何モデル

WindowLightの開発記録。太陽位置近似から窓の4隅を床へ投影し、凸多角形を部屋で切り取る設計を解説。国立天文台の16時刻との限定照合、大気差・低高度・実際の採光との違い、SVGと設定の再編集を整理します。

read log →
GridLights

電源構成の比較を作る:割合と実量を100個の電球で読み解く

電源構成比較GridLightsの開発記録。12か国の年次発電データを最大剰余法で100個の電球に変換し、割合とTWhを比較する設計を解説。欠損を0補完しない7分類、予想と実値、出典付きSVGと実データ検証を整理します。

read log →
SoundLoom

音のお絵かきを作る:実WAVから絵を測り直す設計

音のお絵かきの開発記録。描画を正弦波のPCMとWAVへ変換し、保存音声の周波数をGoertzel法で測って比較する仕組みを解説。音量・測定窓・量子化の限界と、編集・離脱時の古い結果を抑止する検証を整理します。

read log →
MazeLoom

宝探し迷路を作る:寄り道まで含めた最短ルートの探索

宝探し迷路の開発記録。通路を木として生成し、現在地と3つの宝の取得状態を組み合わせて最短ルートを探す仕組みを解説。問題コードの版固定、画面と解答付きPDFの一致、手数と印刷の検証を整理します。

read log →