
소스에는 탐색 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입니다. 줄여 쓴 변수 이름만 읽기 쉽게 바꿨습니다.
배포된 빌드의 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
아직 댓글이 없습니다. 읽다가 궁금한 점이나 보탤 내용을 남겨 주세요.
댓글을 등록하려면 구글로 시작하기를 눌러 닉네임과 메일 주소를 등록해 주세요.
구글로 시작하기