
ソースには探索AIがある
othello-web/lib/ai.ts のAIは、アルファ・ベータ探索で何手か先を読みます。空きマスが多ければ4手、12マス以下なら6手、8マス以下なら最後まで読みます。盤はマスの重み、打てる場所の数、隅、隅の斜め隣のマス(Xマス)で評価します。Qt アプリの aiplayer.cpp も同じ方式です。
このAIに13–0の手順を打たせると、6手目に D6 ではなく B5 を打ちます。記録の手順には進みません。
公開されていたのは別のAIだった
othello.mariago.kr が今配信している画面のコードから、AIの部分を解いてみました。探索がありません。打てる場所ごとに一度ずつ置いてみて、点数が最も高い場所を選びます。
- 点数 = マスの重み×4 + 盤上の石の数 + (自分の石 − 相手の石)×3 − 相手が打てる場所×2
- 隅なら40点を加えます。
このAIに13–0の手順を打たせると、白の4手がすべて記録と一致します。
Webの最後のビルドは、探索AIを保存する13分前に作られました。その後ビルドし直さなかったため、サイトには最初のAIが残りました。
8手目
上の図は、8手目に白が打てる6か所です。そのうち4か所は、打った瞬間に黒が次の手で白をすべて裏返します。一手AIはその中の F4 を-12点で最も高く見て選びました。次の黒の手を見ないので、全滅が分かりません。探索AIは4か所すべてを負ける手と見て B6 を選びます。
二つのAIを対戦させると
三つのAIを100局ずつ対戦させました。黒と白を交互に持ち、同じ盤ばかりにならないよう最初の4手はランダムに打ちました。
- 探索AI 対 一手AI: 100 対 0
- 探索AI 対 ランダム: 99 対 1
- 一手AI 対 ランダム: 86 対 10、引き分け4
11手以内に白を全滅させる黒の手順は、一手AIに対して2つ、探索AIに対して0です。

この段階の実際のコード
公開された画面のコードから解いた一手AIです。短縮された変数名だけを読みやすく変えました。
公開ビルドのAI
const WEIGHTS = [
[100, -20, 10, 5, 5, 10, -20, 100],
[-20, -50, -2, -2, -2, -2, -50, -20],
[10, -2, 1, 1, 1, 1, -2, 10],
[5, -2, 1, 0, 0, 1, -2, 5],
[5, -2, 1, 0, 0, 1, -2, 5],
[10, -2, 1, 1, 1, 1, -2, 10],
[-20, -50, -2, -2, -2, -2, -50, -20],
[100, -20, 10, 5, 5, 10, -20, 100],
];
function suggestMove(board, who) {
const moves = legalMoves(board, who);
if (moves.length === 0) return null;
const opp = opponent(who);
let best = moves[0];
let bestScore = -1e9;
for (const mv of moves) {
const res = playAt(board, mv.r, mv.c, who, "playing");
if (!res.ok) continue;
const mine = who === BLACK ? res.black : res.white;
const theirs = who === BLACK ? res.white : res.black;
let score = 4 * WEIGHTS[mv.r][mv.c];
score += res.black + res.white;
score += (mine - theirs) * 3;
score -= 2 * legalMoves(res.board, opp).length;
if ((mv.r === 0 || mv.r === 7) && (mv.c === 0 || mv.c === 7)) score += 40;
if (score > bestScore) {
bestScore = score;
best = mv;
}
}
return best;
}ソースの探索AIです。
othello-web/lib/ai.ts
function searchDepth(empties: number): number {
if (empties <= 8) return empties;
if (empties <= 12) return 6;
if (empties <= 20) return 5;
return 4;
}
// ...
function negamax(
board: Stone[],
who: Stone,
depth: number,
alpha: number,
beta: number,
): SearchResult {
const moves = legalMoves(board, who);
const opp = opponent(who);
if (moves.length === 0) {
if (legalMoves(board, opp).length === 0) {
const { black, white } = counts(board);
const mine = who === BLACK ? black : white;
const theirs = who === BLACK ? white : black;
return { score: (mine - theirs) * 100_000, move: null };
}
const child = negamax(board, opp, depth - 1, -beta, -alpha);
return { score: -child.score, move: null };
}
if (depth <= 0) {
return { score: evaluate(board, who), move: null };
}
let bestScore = -Infinity;
let bestMove: Point | null = null;
for (const mv of orderMoves(moves)) {
const res = playAt(board, mv.r, mv.c, who, "playing");
if (!res.ok) continue;
const child = negamax(res.board, opp, depth - 1, -beta, -alpha);
const score = -child.score;
if (score > bestScore) {
bestScore = score;
bestMove = mv;
}
if (score > alpha) alpha = score;
if (alpha >= beta) break;
}
return { score: bestScore, move: bestMove };
}
/** 알파·베타 탐색. 초반은 위치·기동력, 종반은 돌 수를 중시. */
export function suggestMove(board: Stone[], who: Stone): Point | null {
const moves = legalMoves(board, who);
if (moves.length === 0) return null;
if (moves.length === 1) return moves[0]!;
const depth = searchDepth(emptyCount(board));
const { move } = negamax(board, who, depth, -Infinity, Infinity);
return move ?? moves[0]!;
}次の記事は「3. VLM は一度も打たなかった: Gemma 4 の読み込み失敗」です。
COMMENTS · CHAPTER 02
一手先しか見ないAI: 公開されたAIとソースが違っていた コメント 0
まだコメントはありません。気になった点や補足があれば残してください。
コメントを登録するには、Googleで始めてニックネームとメールアドレスを登録してください。
Googleで始める