源代码里有搜索 AI

othello-web/lib/ai.ts 中的 AI 用 alpha-beta 搜索向前读几步。空格多时读 4 步,剩 12 格以下时读 6 步,剩 8 格以下时读到终局。盘面通过位置权重、可下位置的数量、角,以及角的斜对角相邻格(X 位)来评估。Qt 应用的 aiplayer.cpp 也是同样的方式。

让这个 AI 按 13–0 的着法顺序来下,第 6 步它会下 B5 而不是 D6,不会走上记录中的着法顺序。

部署的是另一个 AI

我们把 othello.mariago.kr 当前下发的页面代码中的 AI 部分解开来看,里面没有搜索。它在每个可下的位置各试放一次,然后选得分最高的位置。

  • 得分 = 位置权重×4 + 盘上棋子数 + (己方棋子 − 对方棋子)×3 − 对方可下位置数×2
  • 如果是角,再加 40 分。

让这个 AI 按 13–0 的着法顺序来下,白方的四步与记录完全相同。

网页的最后一次构建是在保存搜索 AI 的 13 分钟之前完成的。之后再也没有重新构建,所以网站上留下的是最初的 AI。

第 8 步

上图是第 8 步白方可以下的六个位置。其中四个位置一旦落子,黑方下一步就能把白子全部翻过来。只看一步的 AI 把其中的 F4 以 -12 分评为最高并选择了它。因为它不看黑方的下一步,所以无法察觉全灭。搜索 AI 把这四个位置都视为必败的着法,选择了 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 加载失败”。