소스에는 탐색 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 수순을 두게 하면 백의 네 수가 기록과 모두 같습니다.

웹의 마지막 빌드는 탐색 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 로드 실패’입니다.