이기는 모양

가운데가 한 칸 빈 ○_○○○는 그 빈칸만 채우면 5목이 됩니다. 오목 용어로는 떨어진 사입니다. 한 수로 이 떨어진 사와 다른 줄의 붙은 삼(○○○)을 함께 만들면 이깁니다. 상대는 사를 먼저 막아야 하고, 그 사이에 삼을 열린 사로 만들면 막을 길이 없습니다. 이것이 4-3입니다.

사람은 이 자리를 쉽게 찾습니다. 그런데 지금 AI는 자기 돌로 이 모양을 만들 수 있는데도 두지 않습니다.

AI가 놓친 4-3. 점선 자리에 두면 가로 떨어진 사와 세로 붙은 삼이 생기지만 AI는 줄 왼쪽 끝에 둠
AI가 놓친 4-3. 점선 자리에 두면 가로 떨어진 사와 세로 붙은 삼이 생기지만 AI는 줄 왼쪽 끝에 둠

실제 대국에서

웹 대국 24판을 다시 재생해, AI(백) 차례에 한 수로 4-3을 만들 수 있었던 순간을 찾았습니다. 그런 순간이 7번 있었고, AI가 그 자리에 둔 것은 1번뿐입니다. 떨어진 사로 이길 수 있었던 2번은 모두 놓쳤습니다.

반대로 사람은 같은 모양으로 이겼습니다. 사람이 이긴 22판 중 11판이 4-3으로 끝났습니다.

사람이 이긴 22판이 끝난 모양. 열린 사 9, 4-3(붙은 삼) 6, 4-3(떨어진 삼) 5, 4-4 2
사람이 이긴 22판이 끝난 모양. 열린 사 9, 4-3(붙은 삼) 6, 4-3(떨어진 삼) 5, 4-4 2

아래는 그중 한 판입니다. 흑이 점선 자리에 두자 대각선의 떨어진 사(●●_●●)와 가로의 떨어진 삼(●●_●)이 함께 생겼습니다.

실제 대국의 4-3. 흑 23수로 사와 떨어진 삼이 동시에 생김
실제 대국의 4-3. 흑 23수로 사와 떨어진 삼이 동시에 생김

로컬 AI 시험

웹의 로컬 AI 코드를 그대로 옮겨, 15×15 판에 방향을 무작위로 바꿔 가며 시험했습니다. 자기 4-3 자리를 찾은 비율은 붙은 사(○○○○)와 삼일 때 29%, 떨어진 사(○_○○○)와 삼일 때 27%였습니다. 상대가 다음 수에 4-3을 만들 자리를 미리 막은 비율도 33%에 그쳤습니다. 삼 하나만 막는 것은 붙은 삼 67%, 떨어진 삼 100%였습니다.

로컬 AI 시험. 내 4-3 찾기 붙은 사 29%, 떨어진 사 27%, 상대 4-3 자리 막기 33%, 붙은 삼 막기 67%, 떨어진 삼 막기 100%
로컬 AI 시험. 내 4-3 찾기 붙은 사 29%, 떨어진 사 27%, 상대 4-3 자리 막기 33%, 붙은 삼 막기 67%, 떨어진 삼 막기 100%

왜 못 보나

로컬 AI는 빈칸마다 네 방향의 점수를 따로 매겨 더합니다. 그런데 한 방향은 그 칸에서 오른쪽, 아래쪽 같은 앞쪽으로만 다섯 칸을 세고 뒤쪽은 보지 않습니다. 위 그림처럼 사와 삼의 돌이 4-3 자리의 왼쪽과 위쪽에 있으면, 그 자리에서는 돌이 하나도 안 보입니다. 오히려 줄 왼쪽 끝에서 오른쪽을 보면 돌 셋이 보이니 그쪽을 고릅니다.

또 한 줄씩 점수를 더할 뿐, 두 줄이 한 점에서 만나 4-3이 된다는 것은 따로 보지 않습니다. 이기는 수를 찾는 코드는 바로 5목이 되는 수만 확인합니다. 그래서 4-3은 방향이 우연히 맞을 때만 둡니다.

금수 판정은 엔진이 따로 하고, 엔진은 떨어진 삼이 섞인 삼삼도 금수로 잡습니다. 규칙은 맞게 보고 있고, 두는 쪽의 판단이 부족한 것입니다.

이 단계의 실제 코드

로컬 AI가 빈칸 하나의 점수를 매기는 부분입니다. 앞쪽 다섯 칸만 세는 것을 볼 수 있습니다.

omook-web/lib/ai.tstypescript · 38줄
function linePotential(
  board: Stone[],
  r: number,
  c: number,
  dr: number,
  dc: number,
  color: Stone,
  size: number,
): number {
  let streak = 0;
  let blockedNear = false;
  const rr0 = r - dr;
  const cc0 = c - dc;
  if (inBounds(rr0, cc0, size)) {
    const oc = board[idx(rr0, cc0, size)];
    if (oc !== EMPTY && oc !== color) blockedNear = true;
  }

  let rr = r;
  let cc = c;
  for (let k = 0; k < 5 && inBounds(rr, cc, size); k++, rr += dr, cc += dc) {
    const v = board[idx(rr, cc, size)];
    if (v === color) streak++;
    else if (v === EMPTY) {
      /* open */
    } else break;
  }
  let score = streak * streak * 120;
  if (streak >= 4) score += 50000;
  if (blockedNear && streak >= 4) score = Math.floor(score / 8);
  return score;
}

function scorePlacement(board: Stone[], r: number, c: number, who: Stone, size: number): number {
  let s = 0;
  for (const [dr, dc] of DIRS) s += linePotential(board, r, c, dr, dc, who, size);
  return s;
}

다음 글은 ‘4. VLM이 휴리스틱을 배우고, 휴리스틱이 다시 교사가 되는 계획’입니다.