ソースには探索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同士で100局ずつ打った結果と全滅手順の数
AI同士で100局ずつ打った結果と全滅手順の数

この段階の実際のコード

公開された画面のコードから解いた一手AIです。短縮された変数名だけを読みやすく変えました。

公開ビルドのAIts · 34行
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.tsts · 61行
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 の読み込み失敗」です。