TurnStone は、一人で遊べるリバーシ (8×8) です。ただし主役は AI の強さではありません。AI が今どう考えているかを、盤の上に全部出すことに振ったサービスです。
チェス AI (GambitBoard) を作ったときに残った不満がありました。強くはなったが、なぜその手なのかは誰にも見えない。評価関数も探索も自分で書いたのに、外から見えるのは「AI が指した 1 手」だけです。そこで今回は、評価を隠さない側に振りました。
#🎯 「AI と戦う」ではなく「AI の読みを見せる」
リバーシは、初心者と経験者で打ち方が正反対になる珍しいゲームです。初めての人は「たくさん返せる手」を打ちます。経験者は逆に、序盤はできるだけ返さない。
この差を言葉で説明しても伝わりません。「石数より打てる手の数が大事」と書いたところで、実際に打つときには目の前の枚数に引っ張られます。
なので、打つ前に全部の候補マスへ評価値を出すことにしました。
- 置けるマスすべてに、AI が計算した点数を数字で重ねる
- 点数が高いほど色を濃くする
- 最善手だけ青で囲む
「8 枚返せるマス」が -12 で、「1 枚しか返らないマス」が +41 になる。数字が目の前にあると、直感に反する定石が体験として入ります。
#🧩 評価関数を 1 つの数値に潰さない
普通、評価関数は number を返します。探索はそれしか要らないからです。
でもこのサービスでは、画面に内訳を出したい。そこで戻り値を構造体にしました。
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 マスの表にしました。
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 なのは、意味の重さがまったく違います。後者は相手をほぼ詰ませている状態です。
そこで比率に直しました。
const mobility =
myMoves + foeMoves === 0 ? 0 : (100 * (myMoves - foeMoves)) / (myMoves + foeMoves)これで -100 〜 +100 に正規化され、終盤で相手の選択肢を奪ったことが正しく高評価になります。分母が 0 のとき (両者とも打てない = 終局) を先に外しておかないとゼロ除算になるので、そこだけ明示的に潰しています。
#🪨 確定石は「少なく数える」
角を取ると、そこから辺に沿って続く石も返らなくなります。これを確定石と呼びます。
厳密に判定しようとすると、盤の内側にある石まで含めて「もうどの方向からも挟めない」ことを確かめる必要があり、かなり重い処理になります。1 手ごとに何千局面も評価する探索の中では回りません。
なので、角を起点にした辺方向だけを数える控えめな定義にしました。
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 マスを切ると、係数を入れ替えます。
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 手ずつ独立に読みます。
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 を当てて直しました。
.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 をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。